diff options
Diffstat (limited to 'core')
98 files changed, 1372 insertions, 4953 deletions
diff --git a/core/build.gradle b/core/build.gradle index 953a85a97..b3954c879 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -20,12 +20,15 @@ android { } dependencies { + implementation project(':event') implementation project(':model') implementation project(':net:ssl') implementation project(':net:sync:gpoddernet') implementation project(':net:sync:model') implementation project(':parser:feed') implementation project(':parser:media') + implementation project(':playback:base') + implementation project(':playback:cast') implementation project(':ui:app-start-intent') implementation project(':ui:common') implementation project(':ui:png-icons') @@ -34,6 +37,7 @@ dependencies { implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "androidx.core:core:$coreVersion" implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "androidx.fragment:fragment:$fragmentVersion" implementation "androidx.media:media:$mediaVersion" implementation "androidx.preference:preference:$preferenceVersion" implementation "androidx.work:work-runtime:$workManagerVersion" @@ -55,12 +59,10 @@ dependencies { implementation "com.google.android.exoplayer:exoplayer-core:$exoPlayerVersion" implementation "com.google.android.exoplayer:exoplayer-ui:$exoPlayerVersion" + implementation "com.google.android.exoplayer:extension-okhttp:$exoPlayerVersion" implementation "com.github.AntennaPod:AntennaPod-AudioPlayer:$audioPlayerVersion" // Non-free dependencies: - playApi 'com.google.android.libraries.cast.companionlibrary:ccl:2.9.1' - playApi 'androidx.mediarouter:mediarouter:1.0.0' - playApi "com.google.android.gms:play-services-cast:$playServicesVersion" playApi "com.google.android.support:wearable:$wearableSupportVersion" compileOnly "com.google.android.wearable:wearable:$wearableSupportVersion" diff --git a/core/lint.xml b/core/lint.xml index fd9f5eb99..aa2c50677 100644 --- a/core/lint.xml +++ b/core/lint.xml @@ -8,4 +8,8 @@ <issue id="MissingDefaultResource"> <ignore path="**/values-**/strings.xml" /> </issue> + + <issue id="UnusedResources" severity="error"> + <ignore path="**/values-**/strings.xml" /> + </issue> </lint> diff --git a/core/src/debug/res/drawable-nodpi/ic_launcher_foreground_no_finish.png b/core/src/debug/res/drawable-nodpi/ic_launcher_foreground_no_finish.png Binary files differnew file mode 100644 index 000000000..825421990 --- /dev/null +++ b/core/src/debug/res/drawable-nodpi/ic_launcher_foreground_no_finish.png diff --git a/core/src/free/java/de/danoeh/antennapod/core/CastCallbacks.java b/core/src/free/java/de/danoeh/antennapod/core/CastCallbacks.java deleted file mode 100644 index 2e266c736..000000000 --- a/core/src/free/java/de/danoeh/antennapod/core/CastCallbacks.java +++ /dev/null @@ -1,7 +0,0 @@ -package de.danoeh.antennapod.core; - -/** - * Callbacks for Chromecast support on the core module - */ -public interface CastCallbacks { -} diff --git a/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java b/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java deleted file mode 100644 index 837cb1bd0..000000000 --- a/core/src/free/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java +++ /dev/null @@ -1,54 +0,0 @@ -package de.danoeh.antennapod.core.service.playback; - -import android.content.Context; -import androidx.annotation.StringRes; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; - -/** - * Class intended to work along PlaybackService and provide support for different flavors. - */ -class PlaybackServiceFlavorHelper { - - private final PlaybackService.FlavorHelperCallback callback; - - PlaybackServiceFlavorHelper(Context context, PlaybackService.FlavorHelperCallback callback) { - this.callback = callback; - } - - void initializeMediaPlayer(Context context) { - callback.setMediaPlayer(new LocalPSMP(context, callback.getMediaPlayerCallback())); - } - - void removeCastConsumer() { - // no-op - } - - boolean castDisconnect(boolean castDisconnect) { - return false; - } - - boolean onMediaPlayerInfo(Context context, int code, @StringRes int resourceId) { - return false; - } - - void registerWifiBroadcastReceiver() { - // no-op - } - - void unregisterWifiBroadcastReceiver() { - // no-op - } - - boolean onSharedPreference(String key) { - return false; - } - - void sessionStateAddActionForWear(PlaybackStateCompat.Builder sessionState, String actionName, CharSequence name, int icon) { - // no-op - } - - void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) { - // no-op - } -} diff --git a/core/src/free/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java b/core/src/free/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java new file mode 100644 index 000000000..373b24bc8 --- /dev/null +++ b/core/src/free/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; + +class WearMediaSession { + static void sessionStateAddActionForWear(PlaybackStateCompat.Builder sessionState, String actionName, + CharSequence name, int icon) { + // no-op + } + + static void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) { + // no-op + } +} diff --git a/core/src/free/res/values/strings.xml b/core/src/free/res/values/strings.xml deleted file mode 100644 index fb49bbbe7..000000000 --- a/core/src/free/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="pref_cast_message" translatable="false">@string/pref_cast_message_free_flavor</string> -</resources> diff --git a/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java index 755bec14e..ac67fb042 100644 --- a/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java +++ b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java @@ -30,8 +30,6 @@ public class ClientConfig { public static DownloadServiceCallbacks downloadServiceCallbacks; - public static CastCallbacks castCallbacks; - private static boolean initialized = false; public static synchronized void initialize(Context context) { diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/DiscoveryDefaultUpdateEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/DiscoveryDefaultUpdateEvent.java deleted file mode 100644 index f7757935a..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/DiscoveryDefaultUpdateEvent.java +++ /dev/null @@ -1,6 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class DiscoveryDefaultUpdateEvent { - public DiscoveryDefaultUpdateEvent() { - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/FavoritesEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/FavoritesEvent.java deleted file mode 100644 index cbfcc37e6..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FavoritesEvent.java +++ /dev/null @@ -1,41 +0,0 @@ -package de.danoeh.antennapod.core.event; - -import androidx.annotation.NonNull; - -import org.apache.commons.lang3.builder.ToStringBuilder; -import org.apache.commons.lang3.builder.ToStringStyle; - -import de.danoeh.antennapod.model.feed.FeedItem; - -public class FavoritesEvent { - - public enum Action { - ADDED, REMOVED - } - - private final Action action; - private final FeedItem item; - - private FavoritesEvent(Action action, FeedItem item) { - this.action = action; - this.item = item; - } - - public static FavoritesEvent added(FeedItem item) { - return new FavoritesEvent(Action.ADDED, item); - } - - public static FavoritesEvent removed(FeedItem item) { - return new FavoritesEvent(Action.REMOVED, item); - } - - @NonNull - @Override - public String toString() { - return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) - .append("action", action) - .append("item", item) - .toString(); - } - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/FeedItemEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/FeedItemEvent.java deleted file mode 100644 index 99cb01714..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FeedItemEvent.java +++ /dev/null @@ -1,54 +0,0 @@ -package de.danoeh.antennapod.core.event; - - -import androidx.annotation.NonNull; - -import org.apache.commons.lang3.builder.ToStringBuilder; -import org.apache.commons.lang3.builder.ToStringStyle; - -import java.util.Arrays; -import java.util.List; - -import de.danoeh.antennapod.model.feed.FeedItem; - -public class FeedItemEvent { - - public enum Action { - UPDATE, DELETE_MEDIA - } - - @NonNull - private final Action action; - @NonNull public final List<FeedItem> items; - - private FeedItemEvent(@NonNull Action action, @NonNull List<FeedItem> items) { - this.action = action; - this.items = items; - } - - public static FeedItemEvent deletedMedia(List<FeedItem> items) { - return new FeedItemEvent(Action.DELETE_MEDIA, items); - } - - public static FeedItemEvent deletedMedia(FeedItem... items) { - return deletedMedia(Arrays.asList(items)); - } - - public static FeedItemEvent updated(List<FeedItem> items) { - return new FeedItemEvent(Action.UPDATE, items); - } - - public static FeedItemEvent updated(FeedItem... items) { - return updated(Arrays.asList(items)); - } - - @NonNull - @Override - public String toString() { - return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) - .append("action", action) - .append("items", items) - .toString(); - } - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/FeedListUpdateEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/FeedListUpdateEvent.java deleted file mode 100644 index 4ed8e33ec..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FeedListUpdateEvent.java +++ /dev/null @@ -1,28 +0,0 @@ -package de.danoeh.antennapod.core.event; - -import de.danoeh.antennapod.model.feed.Feed; - -import java.util.ArrayList; -import java.util.List; - -public class FeedListUpdateEvent { - private final List<Long> feeds = new ArrayList<>(); - - public FeedListUpdateEvent(List<Feed> feeds) { - for (Feed feed : feeds) { - this.feeds.add(feed.getId()); - } - } - - public FeedListUpdateEvent(Feed feed) { - feeds.add(feed.getId()); - } - - public FeedListUpdateEvent(long feedId) { - feeds.add(feedId); - } - - public boolean contains(Feed feed) { - return feeds.contains(feed.getId()); - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/MessageEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/MessageEvent.java deleted file mode 100644 index 9fb22b8ea..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/MessageEvent.java +++ /dev/null @@ -1,21 +0,0 @@ -package de.danoeh.antennapod.core.event; - -import androidx.annotation.Nullable; - -public class MessageEvent { - - public final String message; - - @Nullable - public final Runnable action; - - public MessageEvent(String message) { - this(message, null); - } - - public MessageEvent(String message, Runnable action) { - this.message = message; - this.action = action; - } - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackHistoryEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackHistoryEvent.java deleted file mode 100644 index cd3f27bf5..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackHistoryEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class PlaybackHistoryEvent { - - private PlaybackHistoryEvent() { - } - - public static PlaybackHistoryEvent listUpdated() { - return new PlaybackHistoryEvent(); - } - - @Override - public String toString() { - return "PlaybackHistoryEvent"; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackPositionEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackPositionEvent.java deleted file mode 100644 index 3327d8a02..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackPositionEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class PlaybackPositionEvent { - private final int position; - private final int duration; - - public PlaybackPositionEvent(int position, int duration) { - this.position = position; - this.duration = duration; - } - - public int getPosition() { - return position; - } - - public int getDuration() { - return duration; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlayerStatusEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/PlayerStatusEvent.java deleted file mode 100644 index fe7f17968..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlayerStatusEvent.java +++ /dev/null @@ -1,6 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class PlayerStatusEvent { - public PlayerStatusEvent() { - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/QueueEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/QueueEvent.java deleted file mode 100644 index c866939bd..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/QueueEvent.java +++ /dev/null @@ -1,71 +0,0 @@ -package de.danoeh.antennapod.core.event; - -import androidx.annotation.Nullable; - -import org.apache.commons.lang3.builder.ToStringBuilder; -import org.apache.commons.lang3.builder.ToStringStyle; - -import java.util.List; - -import de.danoeh.antennapod.model.feed.FeedItem; - -public class QueueEvent { - - public enum Action { - ADDED, ADDED_ITEMS, SET_QUEUE, REMOVED, IRREVERSIBLE_REMOVED, CLEARED, DELETED_MEDIA, SORTED, MOVED - } - - public final Action action; - public final FeedItem item; - public final int position; - public final List<FeedItem> items; - - - private QueueEvent(Action action, - @Nullable FeedItem item, - @Nullable List<FeedItem> items, - int position) { - this.action = action; - this.item = item; - this.items = items; - this.position = position; - } - - public static QueueEvent added(FeedItem item, int position) { - return new QueueEvent(Action.ADDED, item, null, position); - } - - public static QueueEvent setQueue(List<FeedItem> queue) { - return new QueueEvent(Action.SET_QUEUE, null, queue, -1); - } - - public static QueueEvent removed(FeedItem item) { - return new QueueEvent(Action.REMOVED, item, null, -1); - } - - public static QueueEvent irreversibleRemoved(FeedItem item) { - return new QueueEvent(Action.IRREVERSIBLE_REMOVED, item, null, -1); - } - - public static QueueEvent cleared() { - return new QueueEvent(Action.CLEARED, null, null, -1); - } - - public static QueueEvent sorted(List<FeedItem> sortedQueue) { - return new QueueEvent(Action.SORTED, null, sortedQueue, -1); - } - - public static QueueEvent moved(FeedItem item, int newPosition) { - return new QueueEvent(Action.MOVED, item, null, newPosition); - } - - @Override - public String toString() { - return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) - .append("action", action) - .append("item", item) - .append("items", items) - .append("position", position) - .toString(); - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java deleted file mode 100644 index 2230ee84f..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java +++ /dev/null @@ -1,14 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class ServiceEvent { - public enum Action { - SERVICE_STARTED, - SERVICE_SHUT_DOWN - } - - public final Action action; - - public ServiceEvent(Action action) { - this.action = action; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/SyncServiceEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/SyncServiceEvent.java deleted file mode 100644 index 7aa5f6bf1..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/SyncServiceEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class SyncServiceEvent { - private final int messageResId; - - public SyncServiceEvent(int messageResId) { - this.messageResId = messageResId; - } - - public int getMessageResId() { - return messageResId; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/UnreadItemsUpdateEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/UnreadItemsUpdateEvent.java deleted file mode 100644 index c3efbfe8b..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/UnreadItemsUpdateEvent.java +++ /dev/null @@ -1,6 +0,0 @@ -package de.danoeh.antennapod.core.event; - -public class UnreadItemsUpdateEvent { - public UnreadItemsUpdateEvent() { - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SkipIntroEndingChangedEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/settings/SkipIntroEndingChangedEvent.java deleted file mode 100644 index 583f7b13f..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SkipIntroEndingChangedEvent.java +++ /dev/null @@ -1,25 +0,0 @@ -package de.danoeh.antennapod.core.event.settings; - -public class SkipIntroEndingChangedEvent { - private final int skipIntro; - private final int skipEnding; - private final long feedId; - - public SkipIntroEndingChangedEvent(int skipIntro, int skipEnding, long feedId) { - this.skipIntro= skipIntro; - this.skipEnding = skipEnding; - this.feedId = feedId; - } - - public int getSkipIntro() { - return skipIntro; - } - - public int getSkipEnding() { - return skipEnding; - } - - public long getFeedId() { - return feedId; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SpeedPresetChangedEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/settings/SpeedPresetChangedEvent.java deleted file mode 100644 index 0ac7e1316..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SpeedPresetChangedEvent.java +++ /dev/null @@ -1,19 +0,0 @@ -package de.danoeh.antennapod.core.event.settings; - -public class SpeedPresetChangedEvent { - private final float speed; - private final long feedId; - - public SpeedPresetChangedEvent(float speed, long feedId) { - this.speed = speed; - this.feedId = feedId; - } - - public float getSpeed() { - return speed; - } - - public long getFeedId() { - return feedId; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/VolumeAdaptionChangedEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/settings/VolumeAdaptionChangedEvent.java deleted file mode 100644 index 3905ce68f..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/VolumeAdaptionChangedEvent.java +++ /dev/null @@ -1,21 +0,0 @@ -package de.danoeh.antennapod.core.event.settings; - -import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; - -public class VolumeAdaptionChangedEvent { - private final VolumeAdaptionSetting volumeAdaptionSetting; - private final long feedId; - - public VolumeAdaptionChangedEvent(VolumeAdaptionSetting volumeAdaptionSetting, long feedId) { - this.volumeAdaptionSetting = volumeAdaptionSetting; - this.feedId = feedId; - } - - public VolumeAdaptionSetting getVolumeAdaptionSetting() { - return volumeAdaptionSetting; - } - - public long getFeedId() { - return feedId; - } -} 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 index 82583b7b5..5d685c24f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java @@ -178,7 +178,7 @@ public class LocalFeedUpdater { private static FeedItem createFeedItem(Feed feed, DocumentFile file, Context context) { FeedItem item = new FeedItem(0, file.getName(), UUID.randomUUID().toString(), file.getName(), new Date(file.lastModified()), FeedItem.UNPLAYED, feed); - item.setAutoDownload(false); + item.disableAutoDownload(); long size = file.length(); FeedMedia media = new FeedMedia(0, item, 0, 0, size, file.getType(), 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 defe6c9f8..9b06d2138 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 @@ -11,7 +11,6 @@ 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.module.AppGlideModule; import de.danoeh.antennapod.model.feed.EmbeddedChapterImage; @@ -43,7 +42,7 @@ public class ApGlideModule extends AppGlideModule { public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { 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(String.class, InputStream.class, new NoHttpStringLoader.StreamFactory()); registry.append(EmbeddedChapterImage.class, ByteBuffer.class, new ChapterImageModelLoader.Factory()); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/NoHttpStringLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/NoHttpStringLoader.java new file mode 100644 index 000000000..9cda3b1aa --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/NoHttpStringLoader.java @@ -0,0 +1,39 @@ +package de.danoeh.antennapod.core.glide; + +import android.net.Uri; +import androidx.annotation.NonNull; +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.load.model.StringLoader; + +import java.io.InputStream; + +/** + * StringLoader that does not handle http/https urls. Used to avoid fallback to StringLoader when + * AntennaPod blocks mobile image loading. + */ +public final class NoHttpStringLoader extends StringLoader<InputStream> { + + public static class StreamFactory implements ModelLoaderFactory<String, InputStream> { + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { + return new NoHttpStringLoader(multiFactory.build(Uri.class, InputStream.class)); + } + + @Override + public void teardown() { + // Do nothing. + } + } + + public NoHttpStringLoader(ModelLoader<Uri, InputStream> uriLoader) { + super(uriLoader); + } + + @Override + public boolean handles(@NonNull String model) { + return !model.startsWith("http") && super.handles(model); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java deleted file mode 100644 index e338e0d01..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java +++ /dev/null @@ -1,115 +0,0 @@ -package de.danoeh.antennapod.core.preferences; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; -import de.danoeh.antennapod.core.BuildConfig; -import de.danoeh.antennapod.core.ClientConfig; -import de.danoeh.antennapod.core.sync.SyncService; -import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; - -/** - * Manages preferences for accessing gpodder.net service - */ -public class GpodnetPreferences { - - private GpodnetPreferences(){} - - private static final String TAG = "GpodnetPreferences"; - - private static final String PREF_NAME = "gpodder.net"; - private static final String PREF_GPODNET_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username"; - private static final String PREF_GPODNET_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password"; - private static final String PREF_GPODNET_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID"; - private static final String PREF_GPODNET_HOSTNAME = "prefGpodnetHostname"; - - private static String username; - private static String password; - private static String deviceID; - private static String hosturl; - - private static boolean preferencesLoaded = false; - - private static SharedPreferences getPreferences() { - return ClientConfig.applicationCallbacks.getApplicationInstance().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - } - - private static synchronized void ensurePreferencesLoaded() { - if (!preferencesLoaded) { - SharedPreferences prefs = getPreferences(); - username = prefs.getString(PREF_GPODNET_USERNAME, null); - password = prefs.getString(PREF_GPODNET_PASSWORD, null); - deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null); - hosturl = prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST); - - preferencesLoaded = true; - } - } - - private static void writePreference(String key, String value) { - SharedPreferences.Editor editor = getPreferences().edit(); - editor.putString(key, value); - editor.apply(); - } - - public static String getUsername() { - ensurePreferencesLoaded(); - return username; - } - - public static void setUsername(String username) { - GpodnetPreferences.username = username; - writePreference(PREF_GPODNET_USERNAME, username); - } - - public static String getPassword() { - ensurePreferencesLoaded(); - return password; - } - - public static void setPassword(String password) { - GpodnetPreferences.password = password; - writePreference(PREF_GPODNET_PASSWORD, password); - } - - public static String getDeviceID() { - ensurePreferencesLoaded(); - return deviceID; - } - - public static void setDeviceID(String deviceID) { - GpodnetPreferences.deviceID = deviceID; - writePreference(PREF_GPODNET_DEVICEID, deviceID); - } - - public static String getHosturl() { - ensurePreferencesLoaded(); - return hosturl; - } - - public static void setHosturl(String value) { - if (!value.equals(hosturl)) { - logout(); - writePreference(PREF_GPODNET_HOSTNAME, value); - hosturl = value; - } - } - - /** - * Returns true if device ID, username and password have a non-null value - */ - public static boolean loggedIn() { - ensurePreferencesLoaded(); - return deviceID != null && username != null && password != null; - } - - public static synchronized void logout() { - if (BuildConfig.DEBUG) Log.d(TAG, "Logout: Clearing preferences"); - setUsername(null); - setPassword(null); - setDeviceID(null); - SyncService.clearQueue(ClientConfig.applicationCallbacks.getApplicationInstance()); - UserPreferences.setGpodnetNotificationsEnabled(); - } - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java index 9c73ed9ae..f0c61403f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java @@ -5,11 +5,11 @@ import android.content.SharedPreferences; import androidx.preference.PreferenceManager; import android.util.Log; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.greenrobot.eventbus.EventBus; import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL; diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java index 8b36d88a1..c61dafd1b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java @@ -85,7 +85,7 @@ public class UserPreferences { private static final String PREF_AUTO_DELETE = "prefAutoDelete"; public static final String PREF_SMART_MARK_AS_PLAYED_SECS = "prefSmartMarkAsPlayedSecs"; private static final String PREF_PLAYBACK_SPEED_ARRAY = "prefPlaybackSpeedArray"; - private static final String PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS = "prefPauseForFocusLoss"; + public static final String PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS = "prefPauseForFocusLoss"; private static final String PREF_RESUME_AFTER_CALL = "prefResumeAfterCall"; public static final String PREF_VIDEO_BEHAVIOR = "prefVideoBehavior"; private static final String PREF_TIME_RESPECTS_SPEED = "prefPlaybackTimeRespectsSpeed"; @@ -207,7 +207,7 @@ public class UserPreferences { public static List<Integer> getCompactNotificationButtons() { String[] buttons = TextUtils.split( prefs.getString(PREF_COMPACT_NOTIFICATION_BUTTONS, - String.valueOf(NOTIFICATION_BUTTON_SKIP)), + NOTIFICATION_BUTTON_REWIND + "," + NOTIFICATION_BUTTON_FAST_FORWARD), ","); List<Integer> notificationButtons = new ArrayList<>(); for (String button : buttons) { @@ -467,7 +467,7 @@ public class UserPreferences { } public static boolean shouldPauseForFocusLoss() { - return prefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, false); + return prefs.getBoolean(PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true); } @@ -530,7 +530,8 @@ public class UserPreferences { private static void setAllowMobileFor(String type, boolean allow) { HashSet<String> defaultValue = new HashSet<>(); defaultValue.add("images"); - Set<String> allowed = prefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue); + final Set<String> getValueStringSet = prefs.getStringSet(PREF_MOBILE_UPDATE, defaultValue); + final Set<String> allowed = new HashSet<>(getValueStringSet); if (allow) { allowed.add(type); } else { @@ -609,6 +610,11 @@ public class UserPreferences { public static void setProxyConfig(ProxyConfig config) { SharedPreferences.Editor editor = prefs.edit(); editor.putString(PREF_PROXY_TYPE, config.type.name()); + Proxy.Type type = Proxy.Type.valueOf(config.type.name()); + if (type == Proxy.Type.DIRECT) { + editor.apply(); + return; + } if(TextUtils.isEmpty(config.host)) { editor.remove(PREF_PROXY_HOST); } else { diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java index 26ab4a414..2da0c2db4 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java @@ -20,7 +20,6 @@ import androidx.annotation.VisibleForTesting; import androidx.core.app.ServiceCompat; import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.sync.SyncService; import org.apache.commons.io.FileUtils; import org.greenrobot.eventbus.EventBus; @@ -40,7 +39,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import de.danoeh.antennapod.core.event.DownloadEvent; -import de.danoeh.antennapod.core.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedItemEvent; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; @@ -227,10 +226,6 @@ public class DownloadService extends Service { } unregisterReceiver(cancelDownloadReceiver); - // if this was the initial gpodder sync, i.e. we just synced the feeds successfully, - // it is now time to sync the episode actions - SyncService.sync(this); - // start auto download in case anything new has shown up DBTasks.autodownloadUndownloadedItems(getApplicationContext()); } @@ -326,18 +321,8 @@ public class DownloadService extends Service { if (item == null) { return; } - boolean unknownHost = status.getReason() == DownloadError.ERROR_UNKNOWN_HOST; - boolean unsupportedType = status.getReason() == DownloadError.ERROR_UNSUPPORTED_TYPE; - boolean wrongSize = status.getReason() == DownloadError.ERROR_IO_WRONG_SIZE; - - if (! (unknownHost || unsupportedType || wrongSize)) { - try { - DBWriter.saveFeedItemAutoDownloadFailed(item).get(); - } catch (ExecutionException | InterruptedException e) { - Log.d(TAG, "Ignoring exception while setting item download status"); - e.printStackTrace(); - } - } + item.increaseFailedAutoDownloadAttempts(System.currentTimeMillis()); + DBWriter.setFeedItem(item); // to make lists reload the failed item, we fake an item update EventBus.getDefault().post(FeedItemEvent.updated(item)); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java index 781110f82..cbfb2cede 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import android.text.TextUtils; import android.util.Log; +import de.danoeh.antennapod.core.util.NetworkUtils; import okhttp3.CacheControl; import org.apache.commons.io.IOUtils; @@ -19,8 +20,6 @@ import java.net.URI; import java.net.UnknownHostException; import java.util.Collections; import java.util.Date; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.model.feed.FeedMedia; @@ -39,7 +38,6 @@ public class HttpDownloader extends Downloader { private static final String TAG = "HttpDownloader"; private static final int BUFFER_SIZE = 8 * 1024; - private static final String REGEX_PATTERN_IP_ADDRESS = "([0-9]{1,3}[\\.]){3}[0-9]{1,3}"; public HttpDownloader(@NonNull DownloadRequest request) { super(request); @@ -259,21 +257,14 @@ public class HttpDownloader extends Downloader { onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage()); } catch (IOException e) { e.printStackTrace(); + if (NetworkUtils.wasDownloadBlocked(e)) { + onFail(DownloadError.ERROR_IO_BLOCKED, e.getMessage()); + return; + } String message = e.getMessage(); - if (message != null) { - // Try to parse message for a more detailed error message - Pattern pattern = Pattern.compile(REGEX_PATTERN_IP_ADDRESS); - Matcher matcher = pattern.matcher(message); - if (matcher.find()) { - String ip = matcher.group(); - if (ip.startsWith("127.") || ip.startsWith("0.")) { - onFail(DownloadError.ERROR_IO_BLOCKED, e.getMessage()); - return; - } - } else if (message.contains("Trust anchor for certification path not found")) { - onFail(DownloadError.ERROR_CERTIFICATE, e.getMessage()); - return; - } + if (message != null && message.contains("Trust anchor for certification path not found")) { + onFail(DownloadError.ERROR_CERTIFICATE, e.getMessage()); + return; } onFail(DownloadError.ERROR_IO_ERROR, e.getMessage()); } catch (NullPointerException e) { diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java index 869205b64..f7ed049cd 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java @@ -8,6 +8,7 @@ import android.content.Intent; import android.content.res.Resources; import android.graphics.Bitmap; +import android.os.Build; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; @@ -68,7 +69,8 @@ public class NewEpisodesNotification { intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity")); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.putExtra("fragment_feed_id", feed.getId()); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, + (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); Notification notification = new NotificationCompat.Builder( context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS) @@ -79,6 +81,7 @@ public class NewEpisodesNotification { .setContentIntent(pendingIntent) .setGroup(GROUP_KEY) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setOnlyAlertOnce(true) .setAutoCancel(true) .build(); @@ -92,7 +95,8 @@ public class NewEpisodesNotification { intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity")); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); intent.putExtra("fragment_tag", "EpisodesFragment"); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, + (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); Notification notificationGroupSummary = new NotificationCompat.Builder( context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS) @@ -102,6 +106,7 @@ public class NewEpisodesNotification { .setGroup(GROUP_KEY) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setOnlyAlertOnce(true) .setAutoCancel(true) .build(); notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS, 0, notificationGroupSummary); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java index 8c9035621..541e17cf6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java @@ -6,21 +6,22 @@ import android.util.Log; import androidx.annotation.NonNull; +import org.greenrobot.eventbus.EventBus; + import java.io.File; import java.util.concurrent.ExecutionException; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.service.download.DownloadRequest; import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink; import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.util.DownloadError; -import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import org.greenrobot.eventbus.EventBus; /** * Handles a completed media download. @@ -82,7 +83,7 @@ public class MediaDownloadedHandler implements Runnable { // we've received the media, we don't want to autodownload it again if (item != null) { - item.setAutoDownload(false); + item.disableAutoDownload(); // setFeedItem() signals (via EventBus) that the item has been updated, // so we do it after the enclosing media has been updated above, // to ensure subscribers will get the updated FeedMedia as well @@ -103,7 +104,7 @@ public class MediaDownloadedHandler implements Runnable { EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD) .currentTimestamp() .build(); - SyncService.enqueueEpisodeAction(context, action); + SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java index 0a9bf5f43..7ce06a9fb 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java @@ -5,6 +5,8 @@ import android.net.Uri; import android.text.TextUtils; import android.util.Log; import android.view.SurfaceHolder; +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.DefaultRenderersFactory; @@ -15,6 +17,7 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioAttributes; +import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSourceFactory; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.source.MediaSource; @@ -28,13 +31,16 @@ import com.google.android.exoplayer2.ui.DefaultTrackNameProvider; import com.google.android.exoplayer2.ui.TrackNameProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.HttpDataSource; import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.core.service.download.HttpDownloader; +import de.danoeh.antennapod.core.util.NetworkUtils; import de.danoeh.antennapod.core.util.playback.IPlayer; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -54,7 +60,7 @@ public class ExoPlayerWrapper implements IPlayer { private MediaSource mediaSource; private MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener; private MediaPlayer.OnCompletionListener audioCompletionListener; - private MediaPlayer.OnErrorListener audioErrorListener; + private Consumer<String> audioErrorListener; private MediaPlayer.OnBufferingUpdateListener bufferingUpdateListener; private PlaybackParameters playbackParameters; private MediaPlayer.OnInfoListener infoListener; @@ -98,9 +104,17 @@ public class ExoPlayerWrapper implements IPlayer { } @Override - public void onPlayerError(ExoPlaybackException error) { + public void onPlayerError(@NonNull ExoPlaybackException error) { if (audioErrorListener != null) { - audioErrorListener.onError(null, error.type + ERROR_CODE_OFFSET, 0); + if (NetworkUtils.wasDownloadBlocked(error)) { + audioErrorListener.accept(context.getString(R.string.download_error_blocked)); + } else { + Throwable cause = error.getCause(); + if (cause instanceof HttpDataSource.HttpDataSourceException) { + cause = cause.getCause(); + } + audioErrorListener.accept(cause != null ? cause.getMessage() : error.getMessage()); + } } } @@ -190,18 +204,12 @@ public class ExoPlayerWrapper implements IPlayer { public void setDataSource(String s, String user, String password) throws IllegalArgumentException, IllegalStateException { Log.d(TAG, "setDataSource: " + s); - DefaultHttpDataSourceFactory httpDataSourceFactory = new DefaultHttpDataSourceFactory( - ClientConfig.USER_AGENT, null, - DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, - DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, - true); + OkHttpDataSourceFactory httpDataSourceFactory = new OkHttpDataSourceFactory( + AntennapodHttpClient.getHttpClient(), ClientConfig.USER_AGENT); if (!TextUtils.isEmpty(user) && !TextUtils.isEmpty(password)) { httpDataSourceFactory.getDefaultRequestProperties().set("Authorization", - HttpDownloader.encodeCredentials( - user, - password, - "ISO-8859-1")); + HttpDownloader.encodeCredentials(user, password, "ISO-8859-1")); } DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, null, httpDataSourceFactory); DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); @@ -323,7 +331,7 @@ public class ExoPlayerWrapper implements IPlayer { this.audioSeekCompleteListener = audioSeekCompleteListener; } - void setOnErrorListener(MediaPlayer.OnErrorListener audioErrorListener) { + void setOnErrorListener(Consumer<String> audioErrorListener) { this.audioErrorListener = audioErrorListener; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java index f74e3b9ad..34fc7d699 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java @@ -14,7 +14,12 @@ import android.view.SurfaceHolder; import androidx.media.AudioAttributesCompat; import androidx.media.AudioFocusRequestCompat; import androidx.media.AudioManagerCompat; -import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.event.PlayerErrorEvent; +import de.danoeh.antennapod.event.playback.BufferUpdateEvent; +import de.danoeh.antennapod.event.playback.SpeedChangedEvent; +import de.danoeh.antennapod.core.util.playback.MediaPlayerError; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.antennapod.audio.MediaPlayer; import java.io.File; @@ -35,12 +40,13 @@ import de.danoeh.antennapod.model.playback.MediaType; import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; +import de.danoeh.antennapod.playback.base.RewindAfterPauseUtils; import de.danoeh.antennapod.core.util.playback.AudioPlayer; import de.danoeh.antennapod.core.util.playback.IPlayer; import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import de.danoeh.antennapod.core.util.playback.VideoPlayer; +import org.greenrobot.eventbus.EventBus; /** * Manages the MediaPlayer object of the PlaybackService. @@ -68,6 +74,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { private final PlayerLock playerLock; private final PlayerExecutor executor; private boolean useCallerThread = true; + private boolean isShutDown = false; private CountDownLatch seekLatch; @@ -142,7 +149,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { } public LocalPSMP(@NonNull Context context, - @NonNull PSMPCallback callback) { + @NonNull PlaybackServiceMediaPlayer.PSMPCallback callback) { super(context, callback); this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); this.playerLock = new PlayerLock(); @@ -259,9 +266,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { LocalPSMP.this.startWhenPrepared.set(startWhenPrepared); setPlayerStatus(PlayerStatus.INITIALIZING, media); try { - if (media instanceof FeedMedia && ((FeedMedia) media).getItem() == null) { - ((FeedMedia) media).setItem(DBReader.getFeedItem(((FeedMedia) media).getItemId())); - } + callback.ensureMediaInfoLoaded(media); callback.onMediaChanged(false); setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media), UserPreferences.isSkipSilence()); if (stream) { @@ -294,6 +299,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { } catch (IOException | IllegalStateException e) { e.printStackTrace(); setPlayerStatus(PlayerStatus.ERROR, null); + EventBus.getDefault().postSticky(new PlayerErrorEvent(e.getLocalizedMessage())); } } @@ -402,6 +408,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { } catch (IOException e) { e.printStackTrace(); setPlayerStatus(PlayerStatus.ERROR, null); + EventBus.getDefault().postSticky(new PlayerErrorEvent(e.getLocalizedMessage())); } } playerLock.unlock(); @@ -611,7 +618,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { private void setSpeedSyncAndSkipSilence(float speed, boolean skipSilence) { playerLock.lock(); Log.d(TAG, "Playback speed was set to " + speed); - callback.playbackSpeedChanged(speed); + EventBus.getDefault().post(new SpeedChangedEvent(speed)); mediaPlayer.setPlaybackParams(speed, skipSilence); playerLock.unlock(); } @@ -712,32 +719,22 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { */ @Override public void shutdown() { - executor.shutdown(); if (mediaPlayer != null) { try { - removeMediaPlayerErrorListener(); + clearMediaPlayerListeners(); if (mediaPlayer.isPlaying()) { mediaPlayer.stop(); } } catch (Exception ignore) { } mediaPlayer.release(); + mediaPlayer = null; } + isShutDown = true; + executor.shutdown(); + abandonAudioFocus(); releaseWifiLockIfNecessary(); } - private void removeMediaPlayerErrorListener() { - if (mediaPlayer instanceof VideoPlayer) { - VideoPlayer vp = (VideoPlayer) mediaPlayer; - vp.setOnErrorListener((mp, what, extra) -> true); - } else if (mediaPlayer instanceof AudioPlayer) { - AudioPlayer ap = (AudioPlayer) mediaPlayer; - ap.setOnErrorListener((mediaPlayer, i, i1) -> true); - } else if (mediaPlayer instanceof ExoPlayerWrapper) { - ExoPlayerWrapper ap = (ExoPlayerWrapper) mediaPlayer; - ap.setOnErrorListener((mediaPlayer, i, i1) -> true); - } - } - /** * Releases internally used resources. This method should only be called when the object is not used anymore. * This method is executed on an internal executor service. @@ -857,10 +854,14 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { @Override public void onAudioFocusChange(final int focusChange) { + if (isShutDown) { + return; + } if (!PlaybackService.isRunning) { abandonAudioFocus(); Log.d(TAG, "onAudioFocusChange: PlaybackService is no longer running"); if (focusChange == AudioManager.AUDIOFOCUS_GAIN && pausedBecauseOfTransientAudiofocusLoss) { + pausedBecauseOfTransientAudiofocusLoss = false; new PlaybackServiceStarter(context, getPlayable()) .startWhenPrepared(true) .streamIfLastWasStream() @@ -1004,9 +1005,9 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { return stream; } - private IPlayer setMediaPlayerListeners(IPlayer mp) { + private void setMediaPlayerListeners(IPlayer mp) { if (mp == null || media == null) { - return mp; + return; } if (mp instanceof VideoPlayer) { if (media.getMediaType() != MediaType.VIDEO) { @@ -1033,12 +1034,36 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { ap.setOnCompletionListener(audioCompletionListener); ap.setOnSeekCompleteListener(audioSeekCompleteListener); ap.setOnBufferingUpdateListener(audioBufferingUpdateListener); - ap.setOnErrorListener(audioErrorListener); + ap.setOnErrorListener(message -> EventBus.getDefault().postSticky(new PlayerErrorEvent(message))); ap.setOnInfoListener(audioInfoListener); } else { Log.w(TAG, "Unknown media player: " + mp); } - return mp; + } + + private void clearMediaPlayerListeners() { + if (mediaPlayer instanceof VideoPlayer) { + VideoPlayer vp = (VideoPlayer) mediaPlayer; + vp.setOnCompletionListener(x -> { }); + vp.setOnSeekCompleteListener(x -> { }); + vp.setOnErrorListener((mediaPlayer, i, i1) -> false); + vp.setOnBufferingUpdateListener((mediaPlayer, i) -> { }); + vp.setOnInfoListener((mediaPlayer, i, i1) -> false); + } else if (mediaPlayer instanceof AudioPlayer) { + AudioPlayer ap = (AudioPlayer) mediaPlayer; + ap.setOnCompletionListener(x -> { }); + ap.setOnSeekCompleteListener(x -> { }); + ap.setOnErrorListener((x, y, z) -> false); + ap.setOnBufferingUpdateListener((arg0, percent) -> { }); + ap.setOnInfoListener((arg0, what, extra) -> false); + } else if (mediaPlayer instanceof ExoPlayerWrapper) { + ExoPlayerWrapper ap = (ExoPlayerWrapper) mediaPlayer; + ap.setOnCompletionListener(x -> { }); + ap.setOnSeekCompleteListener(x -> { }); + ap.setOnBufferingUpdateListener((arg0, percent) -> { }); + ap.setOnErrorListener(x -> { }); + ap.setOnInfoListener((arg0, what, extra) -> false); + } } private final MediaPlayer.OnCompletionListener audioCompletionListener = @@ -1052,14 +1077,10 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { } private final MediaPlayer.OnBufferingUpdateListener audioBufferingUpdateListener = - (mp, percent) -> genericOnBufferingUpdate(percent); + (mp, percent) -> EventBus.getDefault().post(BufferUpdateEvent.progressUpdate(0.01f * percent)); private final android.media.MediaPlayer.OnBufferingUpdateListener videoBufferingUpdateListener = - (mp, percent) -> genericOnBufferingUpdate(percent); - - private void genericOnBufferingUpdate(int percent) { - callback.onBufferingUpdate(percent); - } + (mp, percent) -> EventBus.getDefault().post(BufferUpdateEvent.progressUpdate(0.01f * percent)); private final MediaPlayer.OnInfoListener audioInfoListener = (mp, what, extra) -> genericInfoListener(what); @@ -1068,7 +1089,16 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { (mp, what, extra) -> genericInfoListener(what); private boolean genericInfoListener(int what) { - return callback.onMediaPlayerInfo(what, 0); + switch (what) { + case android.media.MediaPlayer.MEDIA_INFO_BUFFERING_START: + EventBus.getDefault().post(BufferUpdateEvent.started()); + return true; + case android.media.MediaPlayer.MEDIA_INFO_BUFFERING_END: + EventBus.getDefault().post(BufferUpdateEvent.ended()); + return true; + default: + return true; + } } private final MediaPlayer.OnErrorListener audioErrorListener = @@ -1084,7 +1114,8 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { private final android.media.MediaPlayer.OnErrorListener videoErrorListener = this::genericOnError; private boolean genericOnError(Object inObj, int what, int extra) { - return callback.onMediaPlayerError(inObj, what, extra); + EventBus.getDefault().postSticky(new PlayerErrorEvent(MediaPlayerError.getErrorString(context, what))); + return true; } private final MediaPlayer.OnSeekCompleteListener audioSeekCompleteListener = @@ -1116,4 +1147,9 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { executor.submit(r); } } + + @Override + public boolean isCasting() { + return false; + } } 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 f192b1675..d5e0140d0 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 @@ -1,5 +1,7 @@ package de.danoeh.antennapod.core.service.playback; +import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL; + import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; @@ -14,20 +16,13 @@ import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.res.Configuration; import android.media.AudioManager; -import android.media.MediaPlayer; import android.net.Uri; import android.os.Binder; import android.os.Build; import android.os.Bundle; import android.os.IBinder; import android.os.Vibrator; -import androidx.preference.PreferenceManager; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; import android.support.v4.media.MediaBrowserCompat; -import androidx.media.MediaBrowserServiceCompat; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; @@ -40,24 +35,38 @@ import android.view.SurfaceHolder; import android.webkit.URLUtil; import android.widget.Toast; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.media.MediaBrowserServiceCompat; +import androidx.preference.PreferenceManager; + +import de.danoeh.antennapod.event.playback.BufferUpdateEvent; +import de.danoeh.antennapod.event.playback.PlaybackServiceEvent; +import de.danoeh.antennapod.event.PlayerErrorEvent; +import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; +import de.danoeh.antennapod.playback.cast.CastPsmp; +import de.danoeh.antennapod.playback.cast.CastStateListener; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.event.MessageEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.ServiceEvent; -import de.danoeh.antennapod.core.event.settings.SkipIntroEndingChangedEvent; -import de.danoeh.antennapod.core.event.settings.SpeedPresetChangedEvent; -import de.danoeh.antennapod.core.event.settings.VolumeAdaptionChangedEvent; -import de.danoeh.antennapod.model.feed.Chapter; -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; -import de.danoeh.antennapod.model.feed.FeedPreferences; -import de.danoeh.antennapod.model.playback.MediaType; +import de.danoeh.antennapod.event.MessageEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.settings.SkipIntroEndingChangedEvent; +import de.danoeh.antennapod.event.settings.SpeedPresetChangedEvent; +import de.danoeh.antennapod.event.settings.VolumeAdaptionChangedEvent; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -66,15 +75,21 @@ import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.FeedSearcher; -import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink; import de.danoeh.antennapod.core.util.FeedItemUtil; import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.core.util.NetworkUtils; import de.danoeh.antennapod.core.util.gui.NotificationUtils; -import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.core.util.playback.PlayableUtils; import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import de.danoeh.antennapod.core.widget.WidgetUpdater; +import de.danoeh.antennapod.model.feed.Chapter; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.feed.FeedPreferences; +import de.danoeh.antennapod.model.playback.MediaType; +import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter; import io.reactivex.Completable; @@ -83,11 +98,6 @@ import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL; /** * Controls the MediaPlayer that plays a FeedMedia-file @@ -98,24 +108,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { */ private static final String TAG = "PlaybackService"; - /** - * Parcelable of type Playable. - */ public static final String EXTRA_PLAYABLE = "PlaybackService.PlayableExtra"; - /** - * True if cast session should disconnect. - */ - public static final String EXTRA_CAST_DISCONNECT = "extra.de.danoeh.antennapod.core.service.castDisconnect"; - /** - * True if media should be streamed. - */ public static final String EXTRA_SHOULD_STREAM = "extra.de.danoeh.antennapod.core.service.shouldStream"; public static final String EXTRA_ALLOW_STREAM_THIS_TIME = "extra.de.danoeh.antennapod.core.service.allowStream"; public static final String EXTRA_ALLOW_STREAM_ALWAYS = "extra.de.danoeh.antennapod.core.service.allowStreamAlways"; - /** - * True if playback should be started immediately after media has been - * prepared. - */ public static final String EXTRA_START_WHEN_PREPARED = "extra.de.danoeh.antennapod.core.service.startWhenPrepared"; public static final String EXTRA_PREPARE_IMMEDIATELY = "extra.de.danoeh.antennapod.core.service.prepareImmediately"; @@ -159,30 +155,22 @@ public class PlaybackService extends MediaBrowserServiceCompat { public static final int EXTRA_CODE_VIDEO = 2; public static final int EXTRA_CODE_CAST = 3; - public static final int NOTIFICATION_TYPE_ERROR = 0; - public static final int NOTIFICATION_TYPE_BUFFER_UPDATE = 2; - /** * Receivers of this intent should update their information about the curently playing media */ public static final int NOTIFICATION_TYPE_RELOAD = 3; + /** - * The state of the sleeptimer changed. + * Set a max number of episodes to load for Android Auto, otherwise there could be performance issues */ - public static final int NOTIFICATION_TYPE_SLEEPTIMER_UPDATE = 4; - public static final int NOTIFICATION_TYPE_BUFFER_START = 5; - public static final int NOTIFICATION_TYPE_BUFFER_END = 6; + public static final int MAX_ANDROID_AUTO_EPISODES_PER_FEED = 100; + /** * No more episodes are going to be played. */ public static final int NOTIFICATION_TYPE_PLAYBACK_END = 7; /** - * Playback speed has changed - */ - public static final int NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE = 8; - - /** * Returned by getPositionSafe() or getDurationSafe() if the playbackService * is in an invalid state. */ @@ -203,10 +191,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { private PlaybackServiceMediaPlayer mediaPlayer; private PlaybackServiceTaskManager taskManager; - private PlaybackServiceFlavorHelper flavorHelper; private PlaybackServiceStateManager stateManager; private Disposable positionEventTimer; private PlaybackServiceNotificationBuilder notificationBuilder; + private CastStateListener castStateListener; private String autoSkippedFeedMediaId = null; @@ -283,14 +271,14 @@ public class PlaybackService extends MediaBrowserServiceCompat { EventBus.getDefault().register(this); taskManager = new PlaybackServiceTaskManager(this, taskManagerCallback); - flavorHelper = new PlaybackServiceFlavorHelper(PlaybackService.this, flavorHelperCallback); PreferenceManager.getDefaultSharedPreferences(this) .registerOnSharedPreferenceChangeListener(prefListener); ComponentName eventReceiver = new ComponentName(getApplicationContext(), MediaButtonReceiver.class); Intent mediaButtonIntent = new Intent(Intent.ACTION_MEDIA_BUTTON); mediaButtonIntent.setComponent(eventReceiver); - PendingIntent buttonReceiverIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent buttonReceiverIntent = PendingIntent.getBroadcast(this, 0, mediaButtonIntent, + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0)); mediaSession = new MediaSessionCompat(getApplicationContext(), TAG, eventReceiver, buttonReceiverIntent); setSessionToken(mediaSession.getSessionToken()); @@ -307,10 +295,34 @@ public class PlaybackService extends MediaBrowserServiceCompat { npe.printStackTrace(); } - flavorHelper.initializeMediaPlayer(PlaybackService.this); + recreateMediaPlayer(); mediaSession.setActive(true); + castStateListener = new CastStateListener(this) { + @Override + public void onSessionStartedOrEnded() { + recreateMediaPlayer(); + } + }; + EventBus.getDefault().post(new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_STARTED)); + } - EventBus.getDefault().post(new ServiceEvent(ServiceEvent.Action.SERVICE_STARTED)); + void recreateMediaPlayer() { + Playable media = null; + boolean wasPlaying = false; + if (mediaPlayer != null) { + media = mediaPlayer.getPlayable(); + wasPlaying = mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING; + mediaPlayer.pause(true, false); + mediaPlayer.shutdown(); + } + mediaPlayer = CastPsmp.getInstanceIfConnected(this, mediaPlayerCallback); + if (mediaPlayer == null) { + mediaPlayer = new LocalPSMP(this, mediaPlayerCallback); // Cast not supported or not connected + } + if (media != null) { + mediaPlayer.playMediaObject(media, !media.localFileAvailable(), wasPlaying, true); + } + isCasting = mediaPlayer.isCasting(); } @Override @@ -326,6 +338,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { stateManager.stopForeground(!UserPreferences.isPersistNotify()); isRunning = false; currentMediaType = MediaType.UNKNOWN; + castStateListener.destroy(); cancelPositionObserver(); PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(prefListener); @@ -339,8 +352,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { unregisterReceiver(audioBecomingNoisy); unregisterReceiver(skipCurrentEpisodeReceiver); unregisterReceiver(pausePlayCurrentEpisodeReceiver); - flavorHelper.removeCastConsumer(); - flavorHelper.unregisterWifiBroadcastReceiver(); mediaPlayer.shutdown(); taskManager.shutdown(); } @@ -370,30 +381,22 @@ public class PlaybackService extends MediaBrowserServiceCompat { .subscribe(queueItems -> mediaSession.setQueue(queueItems), Throwable::printStackTrace); } - private MediaBrowserCompat.MediaItem createBrowsableMediaItemForRoot() { + private MediaBrowserCompat.MediaItem createBrowsableMediaItem( + @StringRes int title, @DrawableRes int icon, int numEpisodes) { Uri uri = new Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority(getResources().getResourcePackageName(R.drawable.ic_playlist_black)) - .appendPath(getResources().getResourceTypeName(R.drawable.ic_playlist_black)) - .appendPath(getResources().getResourceEntryName(R.drawable.ic_playlist_black)) + .authority(getResources().getResourcePackageName(icon)) + .appendPath(getResources().getResourceTypeName(icon)) + .appendPath(getResources().getResourceEntryName(icon)) .build(); - String subtitle = ""; - try { - int count = taskManager.getQueue().size(); - subtitle = getResources().getQuantityString(R.plurals.num_episodes, count, count); - } catch (InterruptedException e) { - e.printStackTrace(); - } - MediaDescriptionCompat description = new MediaDescriptionCompat.Builder() .setIconUri(uri) - .setMediaId(getResources().getString(R.string.queue_label)) - .setTitle(getResources().getString(R.string.queue_label)) - .setSubtitle(subtitle) + .setMediaId(getResources().getString(title)) + .setTitle(getResources().getString(title)) + .setSubtitle(getResources().getQuantityString(R.plurals.num_episodes, numEpisodes, numEpisodes)) .build(); - return new MediaBrowserCompat.MediaItem(description, - MediaBrowserCompat.MediaItem.FLAG_BROWSABLE); + return new MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE); } private MediaBrowserCompat.MediaItem createBrowsableMediaItemForFeed(Feed feed) { @@ -425,42 +428,47 @@ public class PlaybackService extends MediaBrowserServiceCompat { }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { }, Throwable::printStackTrace); + .subscribe( + () -> { + }, e -> { + e.printStackTrace(); + result.sendResult(null); + }); } - private List<MediaBrowserCompat.MediaItem> loadChildrenSynchronous(@NonNull String parentId) { + private List<MediaBrowserCompat.MediaItem> loadChildrenSynchronous(@NonNull String parentId) + throws InterruptedException { List<MediaBrowserCompat.MediaItem> mediaItems = new ArrayList<>(); if (parentId.equals(getResources().getString(R.string.app_name))) { - // Root List - try { - if (!(taskManager.getQueue().isEmpty())) { - mediaItems.add(createBrowsableMediaItemForRoot()); - } - } catch (InterruptedException e) { - e.printStackTrace(); - } + mediaItems.add(createBrowsableMediaItem(R.string.queue_label, R.drawable.ic_playlist_black, + taskManager.getQueue().size())); + mediaItems.add(createBrowsableMediaItem(R.string.downloads_label, R.drawable.ic_download_black, + DBReader.getDownloadedItems().size())); List<Feed> feeds = DBReader.getFeedList(); for (Feed feed : feeds) { mediaItems.add(createBrowsableMediaItemForFeed(feed)); } - } else if (parentId.equals(getResources().getString(R.string.queue_label))) { - // Child List - try { - for (FeedItem feedItem : taskManager.getQueue()) { - FeedMedia media = feedItem.getMedia(); - if (media != null) { - mediaItems.add(media.getMediaItem()); - } - } - } catch (InterruptedException e) { - e.printStackTrace(); - } + return mediaItems; + } + + List<FeedItem> feedItems; + if (parentId.equals(getResources().getString(R.string.queue_label))) { + feedItems = taskManager.getQueue(); + } else if (parentId.equals(getResources().getString(R.string.downloads_label))) { + feedItems = DBReader.getDownloadedItems(); } else if (parentId.startsWith("FeedId:")) { long feedId = Long.parseLong(parentId.split(":")[1]); - List<FeedItem> feedItems = DBReader.getFeedItemList(DBReader.getFeed(feedId)); - for (FeedItem feedItem : feedItems) { - if (feedItem.getMedia() != null && feedItem.getMedia().getMediaItem() != null) { - mediaItems.add(feedItem.getMedia().getMediaItem()); + feedItems = DBReader.getFeedItemList(DBReader.getFeed(feedId)); + } else { + Log.e(TAG, "Parent ID not found: " + parentId); + return null; + } + int count = 0; + for (FeedItem feedItem : feedItems) { + if (feedItem.getMedia() != null && feedItem.getMedia().getMediaItem() != null) { + mediaItems.add(feedItem.getMedia().getMediaItem()); + if (++count >= MAX_ANDROID_AUTO_EPISODES_PER_FEED) { + break; } } } @@ -488,9 +496,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { final int keycode = intent.getIntExtra(MediaButtonReceiver.EXTRA_KEYCODE, -1); final boolean hardwareButton = intent.getBooleanExtra(MediaButtonReceiver.EXTRA_HARDWAREBUTTON, false); - final boolean castDisconnect = intent.getBooleanExtra(EXTRA_CAST_DISCONNECT, false); Playable playable = intent.getParcelableExtra(EXTRA_PLAYABLE); - if (keycode == -1 && playable == null && !castDisconnect) { + if (keycode == -1 && playable == null) { Log.e(TAG, "PlaybackService was started with no arguments"); stateManager.stopService(); return Service.START_NOT_STICKY; @@ -514,7 +521,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { stateManager.stopService(); return Service.START_NOT_STICKY; } - } else if (!flavorHelper.castDisconnect(castDisconnect) && playable != null) { + } else { stateManager.validStartCommandWasReceived(); boolean stream = intent.getBooleanExtra(EXTRA_SHOULD_STREAM, true); boolean allowStreamThisTime = intent.getBooleanExtra(EXTRA_ALLOW_STREAM_THIS_TIME, false); @@ -558,9 +565,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { stateManager.stopService(); }); return Service.START_NOT_STICKY; - } else { - Log.d(TAG, "Did not handle intent to PlaybackService: " + intent); - Log.d(TAG, "Extras: " + intent.getExtras()); } } @@ -598,10 +602,12 @@ public class PlaybackService extends MediaBrowserServiceCompat { PendingIntent pendingIntentAllowThisTime; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { pendingIntentAllowThisTime = PendingIntent.getForegroundService(this, - R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } else { pendingIntentAllowThisTime = PendingIntent.getService(this, - R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_allow_stream_this_time, intentAllowThisTime, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } Intent intentAlwaysAllow = new Intent(intentAllowThisTime); @@ -610,10 +616,12 @@ public class PlaybackService extends MediaBrowserServiceCompat { PendingIntent pendingIntentAlwaysAllow; if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { pendingIntentAlwaysAllow = PendingIntent.getForegroundService(this, - R.id.pending_intent_allow_stream_always, intentAlwaysAllow, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_allow_stream_always, intentAlwaysAllow, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } else { pendingIntentAlwaysAllow = PendingIntent.getService(this, - R.id.pending_intent_allow_stream_always, intentAlwaysAllow, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_allow_stream_always, intentAlwaysAllow, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } NotificationCompat.Builder builder = new NotificationCompat.Builder(this, @@ -783,26 +791,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { } @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - final float[] multiplicators = {0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f}; - float multiplicator = multiplicators[Math.max(0, (int) timeLeft / 1000)]; - Log.d(TAG, "onSleepTimerAlmostExpired: " + multiplicator); - mediaPlayer.setVolume(multiplicator, multiplicator); - } - - @Override - public void onSleepTimerExpired() { - mediaPlayer.pause(true, true); - mediaPlayer.setVolume(1.0f, 1.0f); - sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); - } - - @Override - public void onSleepTimerReset() { - mediaPlayer.setVolume(1.0f, 1.0f); - } - - @Override public WidgetUpdater.WidgetState requestWidgetState() { return new WidgetUpdater.WidgetState(getPlayable(), getStatus(), getCurrentPosition(), getDuration(), getCurrentPlaybackSpeed(), isCasting()); @@ -834,9 +822,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { taskManager.startChapterLoader(newInfo.playable); break; case PAUSED: - if ((UserPreferences.isPersistNotify() || isCasting) && - android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - // do not remove notification on pause based on user pref and whether android version supports expanded notifications + if (UserPreferences.isPersistNotify() || isCasting) { + // do not remove notification on pause based on user pref // Change [Play] button to [Pause] updateNotificationAndMediaSession(newInfo.playable); } else if (!UserPreferences.isPersistNotify() && !isCasting) { @@ -884,16 +871,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { } @Override - public void playbackSpeedChanged(float s) { - sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE, 0); - } - - @Override - public void onBufferingUpdate(int percent) { - sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_UPDATE, percent); - } - - @Override public void onMediaChanged(boolean reloadUI) { Log.d(TAG, "reloadUI callback reached"); if (reloadUI) { @@ -903,43 +880,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { } @Override - public boolean onMediaPlayerInfo(int code, @StringRes int resourceId) { - switch (code) { - case MediaPlayer.MEDIA_INFO_BUFFERING_START: - sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_START, 0); - return true; - case MediaPlayer.MEDIA_INFO_BUFFERING_END: - sendNotificationBroadcast(NOTIFICATION_TYPE_BUFFER_END, 0); - - Playable playable = getPlayable(); - if (getPlayable() instanceof FeedMedia - && playable.getDuration() <= 0 && mediaPlayer.getDuration() > 0) { - // Playable is being streamed and does not have a duration specified in the feed - playable.setDuration(mediaPlayer.getDuration()); - DBWriter.setFeedMedia((FeedMedia) playable); - updateNotificationAndMediaSession(playable); - } - - return true; - default: - return flavorHelper.onMediaPlayerInfo(PlaybackService.this, code, resourceId); - } - } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - final String TAG = "PlaybackSvc.onErrorLtsn"; - Log.w(TAG, "An error has occured: " + what + " " + extra); - if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { - mediaPlayer.pause(true, false); - } - sendNotificationBroadcast(NOTIFICATION_TYPE_ERROR, what); - PlaybackPreferences.writeNoMediaPlaying(); - stateManager.stopService(); - return true; - } - - @Override public void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext) { PlaybackService.this.onPostPlayback(media, ended, skipped, playingNext); @@ -966,7 +906,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { taskManager.cancelWidgetUpdater(); if (playable != null) { if (playable instanceof FeedMedia) { - SyncService.enqueueEpisodePlayed(getApplicationContext(), (FeedMedia) playable, false); + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive(getApplicationContext(), + (FeedMedia) playable, false); } playable.onPlaybackPause(getApplicationContext()); } @@ -977,12 +918,67 @@ public class PlaybackService extends MediaBrowserServiceCompat { return PlaybackService.this.getNextInQueue(currentMedia); } + @Nullable + @Override + public Playable findMedia(@NonNull String url) { + FeedItem item = DBReader.getFeedItemByGuidOrEpisodeUrl(null, url); + return item != null ? item.getMedia() : null; + } + @Override public void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) { PlaybackService.this.onPlaybackEnded(mediaType, stopPlaying); } + + @Override + public void ensureMediaInfoLoaded(@NonNull Playable media) { + if (media instanceof FeedMedia && ((FeedMedia) media).getItem() == null) { + ((FeedMedia) media).setItem(DBReader.getFeedItem(((FeedMedia) media).getItemId())); + } + } }; + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void playerError(PlayerErrorEvent event) { + if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { + mediaPlayer.pause(true, false); + } + PlaybackPreferences.writeNoMediaPlaying(); + stateManager.stopService(); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void bufferUpdate(BufferUpdateEvent event) { + if (event.hasEnded()) { + Playable playable = getPlayable(); + if (getPlayable() instanceof FeedMedia + && playable.getDuration() <= 0 && mediaPlayer.getDuration() > 0) { + // Playable is being streamed and does not have a duration specified in the feed + playable.setDuration(mediaPlayer.getDuration()); + DBWriter.setFeedMedia((FeedMedia) playable); + updateNotificationAndMediaSession(playable); + } + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { + if (event.isOver()) { + mediaPlayer.pause(true, true); + mediaPlayer.setVolume(1.0f, 1.0f); + } else if (event.getTimeLeft() < PlaybackServiceTaskManager.SleepTimer.NOTIFICATION_THRESHOLD) { + final float[] multiplicators = {0.1f, 0.2f, 0.3f, 0.3f, 0.3f, 0.4f, 0.4f, 0.4f, 0.6f, 0.8f}; + float multiplicator = multiplicators[Math.max(0, (int) event.getTimeLeft() / 1000)]; + Log.d(TAG, "onSleepTimerAlmostExpired: " + multiplicator); + mediaPlayer.setVolume(multiplicator, multiplicator); + } else if (event.isCancelled()) { + mediaPlayer.setVolume(1.0f, 1.0f); + } + } + private Playable getNextInQueue(final Playable currentMedia) { if (!(currentMedia instanceof FeedMedia)) { Log.d(TAG, "getNextInQueue(), but playable not an instance of FeedMedia, so not proceeding"); @@ -1110,10 +1106,12 @@ public class PlaybackService extends MediaBrowserServiceCompat { } if (ended || smartMarkAsPlayed) { - SyncService.enqueueEpisodePlayed(getApplicationContext(), media, true); + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive( + getApplicationContext(), media, true); media.onPlaybackCompleted(getApplicationContext()); } else { - SyncService.enqueueEpisodePlayed(getApplicationContext(), media, false); + SynchronizationQueueSink.enqueueEpisodePlayedIfSynchronizationIsActive( + getApplicationContext(), media, false); media.onPlaybackPause(getApplicationContext()); } @@ -1146,12 +1144,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { public void setSleepTimer(long waitingTime) { Log.d(TAG, "Setting sleep timer to " + waitingTime + " milliseconds"); taskManager.setSleepTimer(waitingTime); - sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); } public void disableSleepTimer() { taskManager.disableSleepTimer(); - sendNotificationBroadcast(NOTIFICATION_TYPE_SLEEPTIMER_UPDATE, 0); } private void sendNotificationBroadcast(int type, int code) { @@ -1268,15 +1264,15 @@ public class PlaybackService extends MediaBrowserServiceCompat { // This would give the PIP of videos a play button capabilities = capabilities | PlaybackStateCompat.ACTION_PLAY; if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_WATCH) { - flavorHelper.sessionStateAddActionForWear(sessionState, + WearMediaSession.sessionStateAddActionForWear(sessionState, CUSTOM_ACTION_REWIND, getString(R.string.rewind_label), android.R.drawable.ic_media_rew); - flavorHelper.sessionStateAddActionForWear(sessionState, + WearMediaSession.sessionStateAddActionForWear(sessionState, CUSTOM_ACTION_FAST_FORWARD, getString(R.string.fast_forward_label), android.R.drawable.ic_media_ff); - flavorHelper.mediaSessionSetExtraForWear(mediaSession); + WearMediaSession.mediaSessionSetExtraForWear(mediaSession); } } @@ -1320,7 +1316,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { if (stateManager.hasReceivedValidStartCommand()) { mediaSession.setSessionActivity(PendingIntent.getActivity(this, R.id.pending_intent_player_activity, - PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT)); + PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0))); try { mediaSession.setMetadata(builder.build()); } catch (OutOfMemoryError e) { @@ -1357,7 +1354,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { notificationBuilder.setPlayable(playable); notificationBuilder.setMediaSessionToken(mediaSession.getSessionToken()); notificationBuilder.setPlayerStatus(playerStatus); - notificationBuilder.setCasting(isCasting); notificationBuilder.updatePosition(getCurrentPosition(), getCurrentPlaybackSpeed()); NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); @@ -1566,7 +1562,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { @Override public void onReceive(Context context, Intent intent) { if (TextUtils.equals(intent.getAction(), ACTION_SHUTDOWN_PLAYBACK_SERVICE)) { - EventBus.getDefault().post(new ServiceEvent(ServiceEvent.Action.SERVICE_SHUT_DOWN)); + EventBus.getDefault().post(new PlaybackServiceEvent(PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN)); stateManager.stopService(); } } @@ -1920,96 +1916,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { (sharedPreferences, key) -> { if (UserPreferences.PREF_LOCKSCREEN_BACKGROUND.equals(key)) { updateNotificationAndMediaSession(getPlayable()); - } else { - flavorHelper.onSharedPreference(key); } }; - - interface FlavorHelperCallback { - PlaybackServiceMediaPlayer.PSMPCallback getMediaPlayerCallback(); - - void setMediaPlayer(PlaybackServiceMediaPlayer mediaPlayer); - - PlaybackServiceMediaPlayer getMediaPlayer(); - - void setIsCasting(boolean isCasting); - - void sendNotificationBroadcast(int type, int code); - - void saveCurrentPosition(boolean fromMediaPlayer, Playable playable, int position); - - void setupNotification(boolean connected, PlaybackServiceMediaPlayer.PSMPInfo info); - - MediaSessionCompat getMediaSession(); - - Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter); - - void unregisterReceiver(BroadcastReceiver receiver); - } - - private final FlavorHelperCallback flavorHelperCallback = new FlavorHelperCallback() { - @Override - public PlaybackServiceMediaPlayer.PSMPCallback getMediaPlayerCallback() { - return PlaybackService.this.mediaPlayerCallback; - } - - @Override - public void setMediaPlayer(PlaybackServiceMediaPlayer mediaPlayer) { - PlaybackService.this.mediaPlayer = mediaPlayer; - } - - @Override - public PlaybackServiceMediaPlayer getMediaPlayer() { - return PlaybackService.this.mediaPlayer; - } - - @Override - public void setIsCasting(boolean isCasting) { - PlaybackService.isCasting = isCasting; - stateManager.validStartCommandWasReceived(); - } - - @Override - public void sendNotificationBroadcast(int type, int code) { - PlaybackService.this.sendNotificationBroadcast(type, code); - } - - @Override - public void saveCurrentPosition(boolean fromMediaPlayer, Playable playable, int position) { - PlaybackService.this.saveCurrentPosition(fromMediaPlayer, playable, position); - } - - @Override - public void setupNotification(boolean connected, PlaybackServiceMediaPlayer.PSMPInfo info) { - if (connected) { - PlaybackService.this.updateNotificationAndMediaSession(info.playable); - } else { - PlayerStatus status = info.playerStatus; - if ((status == PlayerStatus.PLAYING || - status == PlayerStatus.SEEKING || - status == PlayerStatus.PREPARING || - UserPreferences.isPersistNotify()) && - android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - PlaybackService.this.updateNotificationAndMediaSession(info.playable); - } else if (!UserPreferences.isPersistNotify()) { - stateManager.stopForeground(true); - } - } - } - - @Override - public MediaSessionCompat getMediaSession() { - return PlaybackService.this.mediaSession; - } - - @Override - public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) { - return PlaybackService.this.registerReceiver(receiver, filter); - } - - @Override - public void unregisterReceiver(BroadcastReceiver receiver) { - PlaybackService.this.unregisterReceiver(receiver); - } - }; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java deleted file mode 100644 index e093383b9..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java +++ /dev/null @@ -1,386 +0,0 @@ -package de.danoeh.antennapod.core.service.playback; - -import android.content.Context; -import android.media.AudioManager; -import android.net.wifi.WifiManager; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import android.util.Log; -import android.util.Pair; -import android.view.SurfaceHolder; - -import java.util.List; -import java.util.concurrent.Future; - -import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.model.playback.Playable; - - -/* - * An inconvenience of an implementation like this is that some members and methods that once were - * private are now protected, allowing for access from classes of the same package, namely - * PlaybackService. A workaround would be to move this to a dedicated package. - */ -/** - * Abstract class that allows for different implementations of the PlaybackServiceMediaPlayer for local - * and remote (cast devices) playback. - */ -public abstract class PlaybackServiceMediaPlayer { - private static final String TAG = "PlaybackSvcMediaPlayer"; - - /** - * Return value of some PSMP methods if the method call failed. - */ - static final int INVALID_TIME = -1; - - private volatile PlayerStatus oldPlayerStatus; - volatile PlayerStatus playerStatus; - - /** - * A wifi-lock that is acquired if the media file is being streamed. - */ - private WifiManager.WifiLock wifiLock; - - final PSMPCallback callback; - final Context context; - - PlaybackServiceMediaPlayer(@NonNull Context context, - @NonNull PSMPCallback callback){ - this.context = context; - this.callback = callback; - - playerStatus = PlayerStatus.STOPPED; - } - - /** - * Starts or prepares playback of the specified Playable object. If another Playable object is already being played, the currently playing - * episode will be stopped and replaced with the new Playable object. If the Playable object is already being played, the method will - * not do anything. - * Whether playback starts immediately depends on the given parameters. See below for more details. - * <p/> - * States: - * During execution of the method, the object will be in the INITIALIZING state. The end state depends on the given parameters. - * <p/> - * If 'prepareImmediately' is set to true, the method will go into PREPARING state and after that into PREPARED state. If - * 'startWhenPrepared' is set to true, the method will additionally go into PLAYING state. - * <p/> - * If an unexpected error occurs while loading the Playable's metadata or while setting the MediaPlayers data source, the object - * will enter the ERROR state. - * <p/> - * This method is executed on an internal executor service. - * - * @param playable The Playable object that is supposed to be played. This parameter must not be null. - * @param stream The type of playback. If false, the Playable object MUST provide access to a locally available file via - * getLocalMediaUrl. If true, the Playable object MUST provide access to a resource that can be streamed by - * the Android MediaPlayer via getStreamUrl. - * @param startWhenPrepared Sets the 'startWhenPrepared' flag. This flag determines whether playback will start immediately after the - * episode has been prepared for playback. Setting this flag to true does NOT mean that the episode will be prepared - * for playback immediately (see 'prepareImmediately' parameter for more details) - * @param prepareImmediately Set to true if the method should also prepare the episode for playback. - */ - public abstract void playMediaObject(@NonNull Playable playable, boolean stream, boolean startWhenPrepared, boolean prepareImmediately); - - /** - * Resumes playback if the PSMP object is in PREPARED or PAUSED state. If the PSMP object is in an invalid state. - * nothing will happen. - * <p/> - * This method is executed on an internal executor service. - */ - public abstract void resume(); - - /** - * Saves the current position and pauses playback. Note that, if audiofocus - * is abandoned, the lockscreen controls will also disapear. - * <p/> - * This method is executed on an internal executor service. - * - * @param abandonFocus is true if the service should release audio focus - * @param reinit is true if service should reinit after pausing if the media - * file is being streamed - */ - public abstract void pause(boolean abandonFocus, boolean reinit); - - /** - * Prepared media player for playback if the service is in the INITALIZED - * state. - * <p/> - * This method is executed on an internal executor service. - */ - public abstract void prepare(); - - /** - * Resets the media player and moves it into INITIALIZED state. - * <p/> - * This method is executed on an internal executor service. - */ - public abstract void reinit(); - - /** - * Seeks to the specified position. If the PSMP object is in an invalid state, this method will do nothing. - * Invalid time values (< 0) will be ignored. - * <p/> - * This method is executed on an internal executor service. - */ - public abstract void seekTo(int t); - - /** - * Seek a specific position from the current position - * - * @param d offset from current position (positive or negative) - */ - public abstract void seekDelta(int d); - - /** - * Returns the duration of the current media object or INVALID_TIME if the duration could not be retrieved. - */ - public abstract int getDuration(); - - /** - * Returns the position of the current media object or INVALID_TIME if the position could not be retrieved. - */ - public abstract int getPosition(); - - public abstract boolean isStartWhenPrepared(); - - public abstract void setStartWhenPrepared(boolean startWhenPrepared); - - /** - * Sets the playback parameters. - * - Speed - * - SkipSilence (ExoPlayer only) - * This method is executed on an internal executor service. - */ - public abstract void setPlaybackParams(final float speed, final boolean skipSilence); - - /** - * Returns the current playback speed. If the playback speed could not be retrieved, 1 is returned. - */ - public abstract float getPlaybackSpeed(); - - /** - * Sets the playback volume. - * This method is executed on an internal executor service. - */ - public abstract void setVolume(float volumeLeft, float volumeRight); - - /** - * Returns true if the mediaplayer can mix stereo down to mono - */ - public abstract boolean canDownmix(); - - public abstract void setDownmix(boolean enable); - - public abstract MediaType getCurrentMediaType(); - - public abstract boolean isStreaming(); - - /** - * Releases internally used resources. This method should only be called when the object is not used anymore. - */ - public abstract void shutdown(); - - /** - * Releases internally used resources. This method should only be called when the object is not used anymore. - * This method is executed on an internal executor service. - */ - public abstract void shutdownQuietly(); - - public abstract void setVideoSurface(SurfaceHolder surface); - - public abstract void resetVideoSurface(); - - /** - * Return width and height of the currently playing video as a pair. - * - * @return Width and height as a Pair or null if the video size could not be determined. The method might still - * return an invalid non-null value if the getVideoWidth() and getVideoHeight() methods of the media player return - * invalid values. - */ - public abstract Pair<Integer, Integer> getVideoSize(); - - /** - * Returns a PSMInfo object that contains information about the current state of the PSMP object. - * - * @return The PSMPInfo object. - */ - public final synchronized PSMPInfo getPSMPInfo() { - return new PSMPInfo(oldPlayerStatus, playerStatus, getPlayable()); - } - - /** - * Returns the current status, if you need the media and the player status together, you should - * use getPSMPInfo() to make sure they're properly synchronized. Otherwise a race condition - * could result in nonsensical results (like a status of PLAYING, but a null playable) - * @return the current player status - */ - public synchronized PlayerStatus getPlayerStatus() { - return playerStatus; - } - - /** - * Returns the current media, if you need the media and the player status together, you should - * use getPSMPInfo() to make sure they're properly synchronized. Otherwise a race condition - * could result in nonsensical results (like a status of PLAYING, but a null playable) - * @return the current media. May be null - */ - public abstract Playable getPlayable(); - - protected abstract void setPlayable(Playable playable); - - public abstract List<String> getAudioTracks(); - - public abstract void setAudioTrack(int track); - - public abstract int getSelectedAudioTrack(); - - public void skip() { - endPlayback(false, true, true, true); - } - - /** - * Ends playback of current media (if any) and moves into INDETERMINATE state, unless - * {@param toStoppedState} is set to true, in which case it moves into STOPPED state. - * - * @see #endPlayback(boolean, boolean, boolean, boolean) - */ - public Future<?> stopPlayback(boolean toStoppedState) { - return endPlayback(false, false, false, toStoppedState); - } - - /** - * Internal method that handles end of playback. - * - * Currently, it has 5 use cases: - * <ul> - * <li>Media playback has completed: call with (true, false, true, true)</li> - * <li>User asks to skip to next episode: call with (false, true, true, true)</li> - * <li>Skipping to next episode due to playback error: call with (false, false, true, true)</li> - * <li>Stopping the media player: call with (false, false, false, true)</li> - * <li>We want to change the media player implementation: call with (false, false, false, false)</li> - * </ul> - * - * @param hasEnded If true, we assume the current media's playback has ended, for - * purposes of post playback processing. - * @param wasSkipped Whether the user chose to skip the episode (by pressing the skip - * button). - * @param shouldContinue If true, the media player should try to load, and possibly play, - * the next item, based on the user preferences and whether such item - * exists. - * @param toStoppedState If true, the playback state gets set to STOPPED if the media player - * is not loading/playing after this call, and the UI will reflect that. - * Only relevant if {@param shouldContinue} is set to false, otherwise - * this method's behavior defaults as if this parameter was true. - * - * @return a Future, just for the purpose of tracking its execution. - */ - protected abstract Future<?> endPlayback(boolean hasEnded, boolean wasSkipped, - boolean shouldContinue, boolean toStoppedState); - - /** - * @return {@code true} if the WifiLock feature should be used, {@code false} otherwise. - */ - protected abstract boolean shouldLockWifi(); - - final synchronized void acquireWifiLockIfNecessary() { - if (shouldLockWifi()) { - if (wifiLock == null) { - wifiLock = ((WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE)) - .createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); - wifiLock.setReferenceCounted(false); - } - wifiLock.acquire(); - } - } - - final synchronized void releaseWifiLockIfNecessary() { - if (wifiLock != null && wifiLock.isHeld()) { - wifiLock.release(); - } - } - - /** - * Sets the player status of the PSMP object. PlayerStatus and media attributes have to be set at the same time - * so that getPSMPInfo can't return an invalid state (e.g. status is PLAYING, but media is null). - * <p/> - * This method will notify the callback about the change of the player status (even if the new status is the same - * as the old one). - * <p/> - * It will also call {@link PSMPCallback#onPlaybackPause(Playable, int)} or {@link PSMPCallback#onPlaybackStart(Playable, int)} - * depending on the status change. - * - * @param newStatus The new PlayerStatus. This must not be null. - * @param newMedia The new playable object of the PSMP object. This can be null. - * @param position The position to be set to the current Playable object in case playback started or paused. - * Will be ignored if given the value of {@link #INVALID_TIME}. - */ - final synchronized void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia, int position) { - Log.d(TAG, this.getClass().getSimpleName() + ": Setting player status to " + newStatus); - - this.oldPlayerStatus = playerStatus; - this.playerStatus = newStatus; - setPlayable(newMedia); - - if (newMedia != null && newStatus != PlayerStatus.INDETERMINATE) { - if (oldPlayerStatus == PlayerStatus.PLAYING && newStatus != PlayerStatus.PLAYING) { - callback.onPlaybackPause(newMedia, position); - } else if (oldPlayerStatus != PlayerStatus.PLAYING && newStatus == PlayerStatus.PLAYING) { - callback.onPlaybackStart(newMedia, position); - } - } - - callback.statusChanged(new PSMPInfo(oldPlayerStatus, playerStatus, getPlayable())); - } - - public boolean isAudioChannelInUse() { - AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - return (audioManager.getMode() != AudioManager.MODE_NORMAL || audioManager.isMusicActive()); - } - - /** - * @see #setPlayerStatus(PlayerStatus, Playable, int) - */ - final void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) { - setPlayerStatus(newStatus, newMedia, INVALID_TIME); - } - - public interface PSMPCallback { - void statusChanged(PSMPInfo newInfo); - - void shouldStop(); - - void playbackSpeedChanged(float s); - - void onBufferingUpdate(int percent); - - void onMediaChanged(boolean reloadUI); - - boolean onMediaPlayerInfo(int code, @StringRes int resourceId); - - boolean onMediaPlayerError(Object inObj, int what, int extra); - - void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext); - - void onPlaybackStart(@NonNull Playable playable, int position); - - void onPlaybackPause(Playable playable, int position); - - Playable getNextInQueue(Playable currentMedia); - - void onPlaybackEnded(MediaType mediaType, boolean stopPlaying); - } - - /** - * Holds information about a PSMP object. - */ - public static class PSMPInfo { - public final PlayerStatus oldPlayerStatus; - public PlayerStatus playerStatus; - public Playable playable; - - PSMPInfo(PlayerStatus oldPlayerStatus, PlayerStatus playerStatus, Playable playable) { - this.oldPlayerStatus = oldPlayerStatus; - this.playerStatus = playerStatus; - this.playable = playable; - } - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java index e7dea192a..c348f5773 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java @@ -31,17 +31,17 @@ import de.danoeh.antennapod.model.playback.Playable; import java.util.ArrayList; import java.util.concurrent.ExecutionException; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.apache.commons.lang3.ArrayUtils; public class PlaybackServiceNotificationBuilder { private static final String TAG = "PlaybackSrvNotification"; private static Bitmap defaultIcon = null; - private Context context; + private final Context context; private Playable playable; private MediaSessionCompat.Token mediaSessionToken; private PlayerStatus playerStatus; - private boolean isCasting; private Bitmap icon; private String position; @@ -140,7 +140,7 @@ public class PlaybackServiceNotificationBuilder { if (playable != null) { notification.setContentTitle(playable.getFeedTitle()); notification.setContentText(playable.getEpisodeTitle()); - addActions(notification, mediaSessionToken, playerStatus, isCasting); + addActions(notification, mediaSessionToken, playerStatus); if (icon != null) { notification.setLargeIcon(icon); @@ -170,26 +170,15 @@ public class PlaybackServiceNotificationBuilder { private PendingIntent getPlayerActivityPendingIntent() { return PendingIntent.getActivity(context, R.id.pending_intent_player_activity, - PlaybackService.getPlayerActivityIntent(context), PendingIntent.FLAG_UPDATE_CURRENT); + PlaybackService.getPlayerActivityIntent(context), PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } private void addActions(NotificationCompat.Builder notification, MediaSessionCompat.Token mediaSessionToken, - PlayerStatus playerStatus, boolean isCasting) { + PlayerStatus playerStatus) { ArrayList<Integer> compactActionList = new ArrayList<>(); int numActions = 0; // we start and 0 and then increment by 1 for each call to addAction - - if (isCasting) { - Intent stopCastingIntent = new Intent(context, PlaybackService.class); - stopCastingIntent.putExtra(PlaybackService.EXTRA_CAST_DISCONNECT, true); - PendingIntent stopCastingPendingIntent = PendingIntent.getService(context, - numActions, stopCastingIntent, PendingIntent.FLAG_UPDATE_CURRENT); - notification.addAction(R.drawable.ic_notification_cast_off, - context.getString(R.string.cast_disconnect_label), - stopCastingPendingIntent); - numActions++; - } - // always let them rewind PendingIntent rewindButtonPendingIntent = getPendingIntentForMediaAction( KeyEvent.KEYCODE_MEDIA_REWIND, numActions); @@ -252,9 +241,11 @@ public class PlaybackServiceNotificationBuilder { intent.putExtra(MediaButtonReceiver.EXTRA_KEYCODE, keycodeValue); if (Build.VERSION.SDK_INT >= 26) { - return PendingIntent.getForegroundService(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getForegroundService(context, requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); } else { - return PendingIntent.getService(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getService(context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } } @@ -266,10 +257,6 @@ public class PlaybackServiceNotificationBuilder { this.playerStatus = playerStatus; } - public void setCasting(boolean casting) { - isCasting = casting; - } - public PlayerStatus getPlayerStatus() { return playerStatus; } 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 a14605e5b..9ca7b6647 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 @@ -7,6 +7,7 @@ import android.os.Vibrator; import androidx.annotation.NonNull; import android.util.Log; +import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.widget.WidgetUpdater; @@ -22,10 +23,9 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.QueueEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.QueueEvent; import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.model.playback.Playable; import io.reactivex.Completable; @@ -244,6 +244,7 @@ public class PlaybackServiceTaskManager { } sleepTimer = new SleepTimer(waitingTime); sleepTimerFuture = schedExecutor.schedule(sleepTimer, 0, TimeUnit.MILLISECONDS); + EventBus.getDefault().post(SleepTimerUpdatedEvent.justEnabled(waitingTime)); } /** @@ -349,10 +350,10 @@ public class PlaybackServiceTaskManager { * Cancels all tasks and shuts down the internal executor service of the PSTM. The object should not be used after * execution of this method. */ - public synchronized void shutdown() { + public void shutdown() { EventBus.getDefault().unregister(this); cancelAllTasks(); - schedExecutor.shutdown(); + schedExecutor.shutdownNow(); } private Runnable useMainThreadIfNecessary(Runnable runnable) { @@ -377,27 +378,11 @@ public class PlaybackServiceTaskManager { private final long waitingTime; private long timeLeft; private ShakeListener shakeListener; - private final Handler handler; public SleepTimer(long waitingTime) { super(); this.waitingTime = waitingTime; this.timeLeft = waitingTime; - - if (UserPreferences.useExoplayer() && Looper.myLooper() == Looper.getMainLooper()) { - // Run callbacks in main thread so they can call ExoPlayer methods themselves - this.handler = new Handler(Looper.getMainLooper()); - } else { - this.handler = null; - } - } - - private void postCallback(Runnable r) { - if (handler == null) { - r.run(); - } else { - handler.post(r); - } } @Override @@ -417,6 +402,7 @@ public class PlaybackServiceTaskManager { timeLeft -= now - lastTick; lastTick = now; + EventBus.getDefault().post(SleepTimerUpdatedEvent.updated(timeLeft)); if (timeLeft < NOTIFICATION_THRESHOLD) { Log.d(TAG, "Sleep timer is about to expire"); if (SleepTimerPreferences.vibrate() && !hasVibrated) { @@ -429,7 +415,6 @@ public class PlaybackServiceTaskManager { if (shakeListener == null && SleepTimerPreferences.shakeToReset()) { shakeListener = new ShakeListener(context, this); } - postCallback(() -> callback.onSleepTimerAlmostExpired(timeLeft)); } if (timeLeft <= 0) { Log.d(TAG, "Sleep timer expired"); @@ -438,11 +423,6 @@ public class PlaybackServiceTaskManager { shakeListener = null; } hasVibrated = false; - if (!Thread.currentThread().isInterrupted()) { - postCallback(callback::onSleepTimerExpired); - } else { - Log.d(TAG, "Sleep timer interrupted"); - } } } } @@ -452,10 +432,8 @@ public class PlaybackServiceTaskManager { } public void restart() { - postCallback(() -> { - setSleepTimer(waitingTime); - callback.onSleepTimerReset(); - }); + EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled()); + setSleepTimer(waitingTime); if (shakeListener != null) { shakeListener.pause(); shakeListener = null; @@ -467,19 +445,13 @@ public class PlaybackServiceTaskManager { if (shakeListener != null) { shakeListener.pause(); } - postCallback(callback::onSleepTimerReset); + EventBus.getDefault().post(SleepTimerUpdatedEvent.cancelled()); } } public interface PSTMCallback { void positionSaverTick(); - void onSleepTimerAlmostExpired(long timeLeft); - - void onSleepTimerExpired(); - - void onSleepTimerReset(); - WidgetUpdater.WidgetState requestWidgetState(); void onChapterLoaded(Playable media); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdater.java index edb8bc3a9..43837a473 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdater.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdater.java @@ -4,6 +4,8 @@ import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.model.feed.FeedPreferences; import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; class PlaybackVolumeUpdater { diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java deleted file mode 100644 index 4f2ae34f8..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlayerStatus.java +++ /dev/null @@ -1,33 +0,0 @@ -package de.danoeh.antennapod.core.service.playback; - -public enum PlayerStatus { - INDETERMINATE(0), // player is currently changing its state, listeners should wait until the player has left this state. - ERROR(-1), - PREPARING(19), - PAUSED(30), - PLAYING(40), - STOPPED(5), - PREPARED(20), - SEEKING(29), - INITIALIZING(9), // playback service is loading the Playable's metadata - INITIALIZED(10); // playback service was started, data source of media player was set. - - private final int statusValue; - private static final PlayerStatus[] fromOrdinalLookup; - - static { - fromOrdinalLookup = PlayerStatus.values(); - } - - PlayerStatus(int val) { - statusValue = val; - } - - public static PlayerStatus fromOrdinal(int o) { - return fromOrdinalLookup[o]; - } - - public boolean isAtLeast(PlayerStatus other) { - return other == null || this.statusValue>=other.statusValue; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java index b5202d79c..cf32eb838 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java @@ -35,7 +35,7 @@ public class AutomaticDownloadAlgorithm { return () -> { // true if we should auto download based on network status - boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable() + boolean networkShouldAutoDl = NetworkUtils.isAutoDownloadAllowed() && UserPreferences.isEnableAutodownload(); // true if we should auto download based on power status @@ -65,7 +65,7 @@ public class AutomaticDownloadAlgorithm { Iterator<FeedItem> it = candidates.iterator(); while (it.hasNext()) { FeedItem item = it.next(); - if (!item.isAutoDownloadable() || FeedItemUtil.isPlaying(item.getMedia()) + if (!item.isAutoDownloadable(System.currentTimeMillis()) || FeedItemUtil.isPlaying(item.getMedia()) || item.getFeed().isLocalFeed()) { it.remove(); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java index 49eca1027..f776fe111 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java @@ -575,7 +575,6 @@ public final class DBReader { @Nullable private static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl, PodDBAdapter adapter) { - Log.d(TAG, "Loading feeditem with guid " + guid + " or episode url " + episodeUrl); try (Cursor cursor = adapter.getFeedItemCursor(guid, episodeUrl)) { if (!cursor.moveToNext()) { return null; @@ -633,8 +632,6 @@ public final class DBReader { * Does NOT load additional attributes like feed or queue state. */ public static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl) { - Log.d(TAG, "getFeedItem() called with: " + "guid = [" + guid + "], episodeUrl = [" + episodeUrl + "]"); - PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); try { @@ -881,7 +878,7 @@ public final class DBReader { int numDownloadedItems = adapter.getNumberOfDownloadedEpisodes(); List<NavDrawerData.DrawerItem> items = new ArrayList<>(); - Map<String, NavDrawerData.FolderDrawerItem> folders = new HashMap<>(); + Map<String, NavDrawerData.TagDrawerItem> folders = new HashMap<>(); for (Feed feed : feeds) { for (String tag : feed.getPreferences().getTags()) { NavDrawerData.FeedDrawerItem drawerItem = new NavDrawerData.FeedDrawerItem(feed, feed.getId(), @@ -890,18 +887,18 @@ public final class DBReader { items.add(drawerItem); continue; } - NavDrawerData.FolderDrawerItem folder; + NavDrawerData.TagDrawerItem folder; if (folders.containsKey(tag)) { folder = folders.get(tag); } else { - folder = new NavDrawerData.FolderDrawerItem(tag); + folder = new NavDrawerData.TagDrawerItem(tag); folders.put(tag, folder); } drawerItem.id |= folder.id; folder.children.add(drawerItem); } } - List<NavDrawerData.FolderDrawerItem> foldersSorted = new ArrayList<>(folders.values()); + List<NavDrawerData.TagDrawerItem> foldersSorted = new ArrayList<>(folders.values()); Collections.sort(foldersSorted, (o1, o2) -> o1.getTitle().compareToIgnoreCase(o2.getTitle())); items.addAll(foldersSorted); 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 719620202..2cb99559a 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 @@ -1,5 +1,7 @@ package de.danoeh.antennapod.core.storage; +import static android.content.Context.MODE_PRIVATE; + import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; @@ -9,22 +11,6 @@ import android.util.Log; import androidx.annotation.VisibleForTesting; -import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.MessageEvent; -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; -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.storage.mapper.FeedCursorMapper; -import de.danoeh.antennapod.core.sync.SyncService; -import de.danoeh.antennapod.core.util.DownloadError; -import de.danoeh.antennapod.core.util.LongList; -import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; import org.greenrobot.eventbus.EventBus; import java.util.ArrayList; @@ -41,7 +27,23 @@ import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.atomic.AtomicBoolean; -import static android.content.Context.MODE_PRIVATE; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.MessageEvent; +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.storage.mapper.FeedCursorMapper; +import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink; +import de.danoeh.antennapod.core.util.DownloadError; +import de.danoeh.antennapod.core.util.LongList; +import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; /** * Provides methods for doing common tasks that use DBReader and DBWriter. @@ -491,7 +493,7 @@ public final class DBTasks { .position(oldItem.getMedia().getDuration() / 1000) .total(oldItem.getMedia().getDuration() / 1000) .build(); - SyncService.enqueueEpisodeAction(context, action); + SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action); } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java index 46ab7502b..4e0a6aeda 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java @@ -73,7 +73,7 @@ class DBUpgrader { } if (oldVersion <= 9) { db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS - + " ADD COLUMN " + PodDBAdapter.KEY_AUTO_DOWNLOAD + + " ADD COLUMN " + PodDBAdapter.KEY_AUTO_DOWNLOAD_ENABLED + " INTEGER DEFAULT 1"); } if (oldVersion <= 10) { @@ -121,10 +121,10 @@ class DBUpgrader { } if (oldVersion <= 14) { db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS - + " ADD COLUMN " + PodDBAdapter.KEY_AUTO_DOWNLOAD + " INTEGER"); + + " ADD COLUMN " + PodDBAdapter.KEY_AUTO_DOWNLOAD_ATTEMPTS + " INTEGER"); db.execSQL("UPDATE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS - + " SET " + PodDBAdapter.KEY_AUTO_DOWNLOAD + " = " - + "(SELECT " + PodDBAdapter.KEY_AUTO_DOWNLOAD + + " SET " + PodDBAdapter.KEY_AUTO_DOWNLOAD_ATTEMPTS + " = " + + "(SELECT " + PodDBAdapter.KEY_AUTO_DOWNLOAD_ENABLED + " FROM " + PodDBAdapter.TABLE_NAME_FEEDS + " WHERE " + PodDBAdapter.TABLE_NAME_FEEDS + "." + PodDBAdapter.KEY_ID + " = " + PodDBAdapter.TABLE_NAME_FEED_ITEMS + "." + PodDBAdapter.KEY_FEED + ")"); @@ -322,6 +322,10 @@ class DBUpgrader { db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS + " ADD COLUMN " + PodDBAdapter.KEY_FEED_TAGS + " TEXT;"); } + if (oldVersion < 2050000) { + db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS + + " ADD COLUMN " + PodDBAdapter.KEY_MINIMAL_DURATION_FILTER + " INTEGER DEFAULT -1"); + } } } 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 34ea5e207..0e996c6c8 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 @@ -7,8 +7,6 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import de.danoeh.antennapod.core.sync.SyncService; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; import org.greenrobot.eventbus.EventBus; import java.io.File; @@ -25,30 +23,31 @@ import java.util.concurrent.TimeUnit; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.event.DownloadLogEvent; -import de.danoeh.antennapod.core.event.FavoritesEvent; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.MessageEvent; -import de.danoeh.antennapod.core.event.PlaybackHistoryEvent; -import de.danoeh.antennapod.core.event.QueueEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; -import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.event.FavoritesEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.MessageEvent; +import de.danoeh.antennapod.event.playback.PlaybackHistoryEvent; +import de.danoeh.antennapod.event.QueueEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.feed.FeedEvent; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; -import de.danoeh.antennapod.model.feed.FeedPreferences; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink; import de.danoeh.antennapod.core.util.FeedItemPermutors; import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.Permutor; +import de.danoeh.antennapod.core.util.playback.PlayableUtils; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.feed.FeedPreferences; import de.danoeh.antennapod.model.feed.SortOrder; import de.danoeh.antennapod.model.playback.Playable; -import de.danoeh.antennapod.core.util.playback.PlayableUtils; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; /** * Provides methods for writing data to AntennaPod's database. @@ -132,13 +131,11 @@ public class DBWriter { } // Gpodder: queue delete action for synchronization - if (GpodnetPreferences.loggedIn()) { - FeedItem item = media.getItem(); - EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE) - .currentTimestamp() - .build(); - SyncService.enqueueEpisodeAction(context, action); - } + FeedItem item = media.getItem(); + EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE) + .currentTimestamp() + .build(); + SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action); } EventBus.getDefault().post(FeedItemEvent.deletedMedia(Collections.singletonList(media.getItem()))); return true; @@ -170,7 +167,7 @@ public class DBWriter { adapter.removeFeed(feed); adapter.close(); - SyncService.enqueueFeedRemoved(context, feed.getDownload_url()); + SynchronizationQueueSink.enqueueFeedRemovedIfSynchronizationIsActive(context, feed.getDownload_url()); EventBus.getDefault().post(new FeedListUpdateEvent(feed)); }); } @@ -782,7 +779,7 @@ public class DBWriter { adapter.close(); for (Feed feed : feeds) { - SyncService.enqueueFeedAdded(context, feed.getDownload_url()); + SynchronizationQueueSink.enqueueFeedAddedIfSynchronizationIsActive(context, feed.getDownload_url()); } BackupManager backupManager = new BackupManager(context); @@ -953,25 +950,6 @@ public class DBWriter { }); } - public static Future<?> saveFeedItemAutoDownloadFailed(final FeedItem feedItem) { - return dbExec.submit(() -> { - int failedAttempts = feedItem.getFailedAutoDownloadAttempts() + 1; - long autoDownload; - if (!feedItem.getAutoDownload() || failedAttempts >= 10) { - autoDownload = 0; // giving up, disable auto download - feedItem.setAutoDownload(false); - } else { - long now = System.currentTimeMillis(); - autoDownload = (now / 10) * 10 + failedAttempts; - } - final PodDBAdapter adapter = PodDBAdapter.getInstance(); - adapter.open(); - adapter.setFeedItemAutoDownload(feedItem, autoDownload); - adapter.close(); - EventBus.getDefault().post(new UnreadItemsUpdateEvent()); - }); - } - /** * Set filter of the feed * diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/NavDrawerData.java b/core/src/main/java/de/danoeh/antennapod/core/storage/NavDrawerData.java index 7ca90d687..1ec58216a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/NavDrawerData.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/NavDrawerData.java @@ -30,7 +30,7 @@ public class NavDrawerData { public abstract static class DrawerItem { public enum Type { - FOLDER, FEED + TAG, FEED } public final Type type; @@ -55,14 +55,14 @@ public class NavDrawerData { public abstract int getCounter(); } - public static class FolderDrawerItem extends DrawerItem { + public static class TagDrawerItem extends DrawerItem { public final List<DrawerItem> children = new ArrayList<>(); public final String name; public boolean isOpen; - public FolderDrawerItem(String name) { + public TagDrawerItem(String name) { // Keep IDs >0 but make room for many feeds - super(DrawerItem.Type.FOLDER, Math.abs((long) name.hashCode()) << 20); + super(DrawerItem.Type.TAG, Math.abs((long) name.hashCode()) << 20); this.name = name; } 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 85ce2dc99..719e546b5 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 @@ -53,7 +53,7 @@ public class PodDBAdapter { private static final String TAG = "PodDBAdapter"; public static final String DATABASE_NAME = "Antennapod.db"; - public static final int VERSION = 2030000; + public static final int VERSION = 2050000; /** * Maximum number of arguments for IN-operator. @@ -97,7 +97,8 @@ public class PodDBAdapter { public static final String KEY_DOWNLOADSTATUS_TITLE = "title"; public static final String KEY_CHAPTER_TYPE = "type"; public static final String KEY_PLAYBACK_COMPLETION_DATE = "playback_completion_date"; - public static final String KEY_AUTO_DOWNLOAD = "auto_download"; + public static final String KEY_AUTO_DOWNLOAD_ATTEMPTS = "auto_download"; + public static final String KEY_AUTO_DOWNLOAD_ENABLED = "auto_download"; // Both tables use the same key public static final String KEY_KEEP_UPDATED = "keep_updated"; public static final String KEY_AUTO_DELETE_ACTION = "auto_delete_action"; public static final String KEY_FEED_VOLUME_ADAPTION = "feed_volume_adaption"; @@ -113,6 +114,7 @@ public class PodDBAdapter { public static final String KEY_LAST_PLAYED_TIME = "last_played_time"; public static final String KEY_INCLUDE_FILTER = "include_filter"; public static final String KEY_EXCLUDE_FILTER = "exclude_filter"; + public static final String KEY_MINIMAL_DURATION_FILTER = "minimal_duration_filter"; public static final String KEY_FEED_PLAYBACK_SPEED = "feed_playback_speed"; public static final String KEY_FEED_SKIP_INTRO = "feed_skip_intro"; public static final String KEY_FEED_SKIP_ENDING = "feed_skip_ending"; @@ -140,11 +142,12 @@ public class PodDBAdapter { + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT," + KEY_LASTUPDATE + " TEXT," + KEY_LANGUAGE + " TEXT," + KEY_AUTHOR + " TEXT," + KEY_IMAGE_URL + " TEXT," + KEY_TYPE + " TEXT," - + KEY_FEED_IDENTIFIER + " TEXT," + KEY_AUTO_DOWNLOAD + " INTEGER DEFAULT 1," + + KEY_FEED_IDENTIFIER + " TEXT," + KEY_AUTO_DOWNLOAD_ENABLED + " INTEGER DEFAULT 1," + KEY_USERNAME + " TEXT," + KEY_PASSWORD + " TEXT," + KEY_INCLUDE_FILTER + " TEXT DEFAULT ''," + KEY_EXCLUDE_FILTER + " TEXT DEFAULT ''," + + KEY_MINIMAL_DURATION_FILTER + " INTEGER DEFAULT -1," + KEY_KEEP_UPDATED + " INTEGER DEFAULT 1," + KEY_IS_PAGED + " INTEGER DEFAULT 0," + KEY_NEXT_PAGE_LINK + " TEXT," @@ -167,7 +170,7 @@ public class PodDBAdapter { + KEY_MEDIA + " INTEGER," + KEY_FEED + " INTEGER," + KEY_HAS_CHAPTERS + " INTEGER," + KEY_ITEM_IDENTIFIER + " TEXT," + KEY_IMAGE_URL + " TEXT," - + KEY_AUTO_DOWNLOAD + " INTEGER)"; + + KEY_AUTO_DOWNLOAD_ATTEMPTS + " INTEGER)"; private static final String CREATE_TABLE_FEED_MEDIA = "CREATE TABLE " + TABLE_NAME_FEED_MEDIA + " (" + TABLE_PRIMARY_KEY + KEY_DURATION @@ -244,7 +247,7 @@ public class PodDBAdapter { TABLE_NAME_FEEDS + "." + KEY_IMAGE_URL, TABLE_NAME_FEEDS + "." + KEY_TYPE, TABLE_NAME_FEEDS + "." + KEY_FEED_IDENTIFIER, - TABLE_NAME_FEEDS + "." + KEY_AUTO_DOWNLOAD, + TABLE_NAME_FEEDS + "." + KEY_AUTO_DOWNLOAD_ENABLED, TABLE_NAME_FEEDS + "." + KEY_KEEP_UPDATED, TABLE_NAME_FEEDS + "." + KEY_IS_PAGED, TABLE_NAME_FEEDS + "." + KEY_NEXT_PAGE_LINK, @@ -257,6 +260,7 @@ public class PodDBAdapter { TABLE_NAME_FEEDS + "." + KEY_FEED_VOLUME_ADAPTION, TABLE_NAME_FEEDS + "." + KEY_INCLUDE_FILTER, TABLE_NAME_FEEDS + "." + KEY_EXCLUDE_FILTER, + TABLE_NAME_FEEDS + "." + KEY_MINIMAL_DURATION_FILTER, TABLE_NAME_FEEDS + "." + KEY_FEED_PLAYBACK_SPEED, TABLE_NAME_FEEDS + "." + KEY_FEED_TAGS, TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_INTRO, @@ -292,7 +296,7 @@ public class PodDBAdapter { + TABLE_NAME_FEED_ITEMS + "." + KEY_HAS_CHAPTERS + ", " + TABLE_NAME_FEED_ITEMS + "." + KEY_ITEM_IDENTIFIER + ", " + TABLE_NAME_FEED_ITEMS + "." + KEY_IMAGE_URL + ", " - + TABLE_NAME_FEED_ITEMS + "." + KEY_AUTO_DOWNLOAD; + + TABLE_NAME_FEED_ITEMS + "." + KEY_AUTO_DOWNLOAD_ATTEMPTS; private static final String KEYS_FEED_MEDIA = TABLE_NAME_FEED_MEDIA + "." + KEY_ID + " AS " + SELECT_KEY_MEDIA_ID + ", " @@ -442,7 +446,7 @@ public class PodDBAdapter { throw new IllegalArgumentException("Feed ID of preference must not be null"); } ContentValues values = new ContentValues(); - values.put(KEY_AUTO_DOWNLOAD, prefs.getAutoDownload()); + values.put(KEY_AUTO_DOWNLOAD_ENABLED, prefs.getAutoDownload()); values.put(KEY_KEEP_UPDATED, prefs.getKeepUpdated()); values.put(KEY_AUTO_DELETE_ACTION, prefs.getAutoDeleteAction().ordinal()); values.put(KEY_FEED_VOLUME_ADAPTION, prefs.getVolumeAdaptionSetting().toInteger()); @@ -450,6 +454,7 @@ public class PodDBAdapter { values.put(KEY_PASSWORD, prefs.getPassword()); values.put(KEY_INCLUDE_FILTER, prefs.getFilter().getIncludeFilter()); values.put(KEY_EXCLUDE_FILTER, prefs.getFilter().getExcludeFilter()); + values.put(KEY_MINIMAL_DURATION_FILTER, prefs.getFilter().getMinimalDurationFilter()); values.put(KEY_FEED_PLAYBACK_SPEED, prefs.getFeedPlaybackSpeed()); values.put(KEY_FEED_TAGS, prefs.getTagsAsString()); values.put(KEY_FEED_SKIP_INTRO, prefs.getFeedSkipIntro()); @@ -645,7 +650,7 @@ public class PodDBAdapter { } values.put(KEY_HAS_CHAPTERS, item.getChapters() != null || item.hasChapters()); values.put(KEY_ITEM_IDENTIFIER, item.getItemIdentifier()); - values.put(KEY_AUTO_DOWNLOAD, item.getAutoDownload()); + values.put(KEY_AUTO_DOWNLOAD_ATTEMPTS, item.getAutoDownloadAttemptsAndTime()); values.put(KEY_IMAGE_URL, item.getImageUrl()); if (item.getId() == 0) { @@ -761,13 +766,6 @@ public class PodDBAdapter { return status.getId(); } - public void setFeedItemAutoDownload(FeedItem feedItem, long autoDownload) { - ContentValues values = new ContentValues(); - values.put(KEY_AUTO_DOWNLOAD, autoDownload); - db.update(TABLE_NAME_FEED_ITEMS, values, KEY_ID + "=?", - new String[]{String.valueOf(feedItem.getId())}); - } - public void setFavorites(List<FeedItem> favorites) { ContentValues values = new ContentValues(); try { @@ -1123,7 +1121,6 @@ public class PodDBAdapter { + " INNER JOIN " + TABLE_NAME_FEEDS + " ON " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + TABLE_NAME_FEEDS + "." + KEY_ID + " WHERE " + whereClauseCondition; - Log.d(TAG, "SQL: " + query); return db.rawQuery(query, null); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemCursorMapper.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemCursorMapper.java index 19695ca95..ca0834339 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemCursorMapper.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemCursorMapper.java @@ -25,7 +25,7 @@ public abstract class FeedItemCursorMapper { int indexHasChapters = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_HAS_CHAPTERS); int indexRead = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_READ); int indexItemIdentifier = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_ITEM_IDENTIFIER); - int indexAutoDownload = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_AUTO_DOWNLOAD); + int indexAutoDownload = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_AUTO_DOWNLOAD_ATTEMPTS); int indexImageUrl = cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_IMAGE_URL); long id = cursor.getInt(indexId); diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedMediaCursorMapper.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedMediaCursorMapper.java index 608fce5c4..0dc3dc231 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedMediaCursorMapper.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedMediaCursorMapper.java @@ -36,7 +36,7 @@ public abstract class FeedMediaCursorMapper { } Boolean hasEmbeddedPicture; - switch (cursor.getInt(cursor.getColumnIndex(PodDBAdapter.KEY_HAS_EMBEDDED_PICTURE))) { + switch (cursor.getInt(cursor.getColumnIndexOrThrow(PodDBAdapter.KEY_HAS_EMBEDDED_PICTURE))) { case 1: hasEmbeddedPicture = Boolean.TRUE; break; diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedPreferencesCursorMapper.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedPreferencesCursorMapper.java index cab6ea618..f062609b6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedPreferencesCursorMapper.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedPreferencesCursorMapper.java @@ -21,7 +21,7 @@ public abstract class FeedPreferencesCursorMapper { @NonNull public static FeedPreferences convert(@NonNull Cursor cursor) { int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID); - int indexAutoDownload = cursor.getColumnIndex(PodDBAdapter.KEY_AUTO_DOWNLOAD); + int indexAutoDownload = cursor.getColumnIndex(PodDBAdapter.KEY_AUTO_DOWNLOAD_ENABLED); int indexAutoRefresh = cursor.getColumnIndex(PodDBAdapter.KEY_KEEP_UPDATED); int indexAutoDeleteAction = cursor.getColumnIndex(PodDBAdapter.KEY_AUTO_DELETE_ACTION); int indexVolumeAdaption = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_VOLUME_ADAPTION); @@ -29,6 +29,7 @@ public abstract class FeedPreferencesCursorMapper { int indexPassword = cursor.getColumnIndex(PodDBAdapter.KEY_PASSWORD); int indexIncludeFilter = cursor.getColumnIndex(PodDBAdapter.KEY_INCLUDE_FILTER); int indexExcludeFilter = cursor.getColumnIndex(PodDBAdapter.KEY_EXCLUDE_FILTER); + int indexMinimalDurationFilter = cursor.getColumnIndex(PodDBAdapter.KEY_MINIMAL_DURATION_FILTER); int indexFeedPlaybackSpeed = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_PLAYBACK_SPEED); int indexAutoSkipIntro = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_INTRO); int indexAutoSkipEnding = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_ENDING); @@ -47,6 +48,7 @@ public abstract class FeedPreferencesCursorMapper { String password = cursor.getString(indexPassword); String includeFilter = cursor.getString(indexIncludeFilter); String excludeFilter = cursor.getString(indexExcludeFilter); + int minimalDurationFilter = cursor.getInt(indexMinimalDurationFilter); float feedPlaybackSpeed = cursor.getFloat(indexFeedPlaybackSpeed); int feedAutoSkipIntro = cursor.getInt(indexAutoSkipIntro); int feedAutoSkipEnding = cursor.getInt(indexAutoSkipEnding); @@ -62,7 +64,7 @@ public abstract class FeedPreferencesCursorMapper { volumeAdaptionSetting, username, password, - new FeedFilter(includeFilter, excludeFilter), + new FeedFilter(includeFilter, excludeFilter, minimalDurationFilter), feedPlaybackSpeed, feedAutoSkipIntro, feedAutoSkipEnding, diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/EpisodeActionFilter.java b/core/src/main/java/de/danoeh/antennapod/core/sync/EpisodeActionFilter.java index c74356d98..184f24793 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/EpisodeActionFilter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/EpisodeActionFilter.java @@ -22,7 +22,6 @@ public class EpisodeActionFilter { Map<Pair<String, String>, EpisodeAction> localMostRecentPlayActions = createUniqueLocalMostRecentPlayActions(queuedEpisodeActions); for (EpisodeAction remoteAction : remoteActions) { - Log.d(TAG, "Processing remoteAction: " + remoteAction.toString()); Pair<String, String> key = new Pair<>(remoteAction.getPodcast(), remoteAction.getEpisode()); switch (remoteAction.getAction()) { case NEW: diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java new file mode 100644 index 000000000..e7dbbbd3c --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/LockingAsyncExecutor.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.core.sync; + +import java.util.concurrent.locks.ReentrantLock; + +import io.reactivex.Completable; +import io.reactivex.schedulers.Schedulers; + +public class LockingAsyncExecutor { + + static final ReentrantLock lock = new ReentrantLock(); + + /** + * Take the lock and execute runnable (to prevent changes to preferences being lost when enqueueing while sync is + * in progress). If the lock is free, the runnable is directly executed in the calling thread to prevent overhead. + */ + public static void executeLockedAsync(Runnable runnable) { + if (lock.tryLock()) { + try { + runnable.run(); + } finally { + lock.unlock(); + } + } else { + Completable.fromRunnable(() -> { + lock.lock(); + try { + runnable.run(); + } finally { + lock.unlock(); + } + }).subscribeOn(Schedulers.io()) + .subscribe(); + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java index 9803a29db..82896382d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java @@ -5,7 +5,7 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; +import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; @@ -20,12 +20,16 @@ import androidx.work.WorkManager; import androidx.work.Worker; import androidx.work.WorkerParameters; +import org.apache.commons.lang3.StringUtils; +import org.greenrobot.eventbus.EventBus; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.event.SyncServiceEvent; -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.event.SyncServiceEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.core.storage.DBReader; @@ -33,10 +37,14 @@ import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.DownloadRequestException; import de.danoeh.antennapod.core.storage.DownloadRequester; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueStorage; import de.danoeh.antennapod.core.util.FeedItemUtil; import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.URLChecker; import de.danoeh.antennapod.core.util.gui.NotificationUtils; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; import de.danoeh.antennapod.net.sync.model.EpisodeAction; import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; @@ -44,162 +52,67 @@ import de.danoeh.antennapod.net.sync.model.ISyncService; import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; import de.danoeh.antennapod.net.sync.model.SyncServiceException; import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; -import io.reactivex.Completable; -import io.reactivex.schedulers.Schedulers; - -import org.apache.commons.lang3.StringUtils; -import org.greenrobot.eventbus.EventBus; -import org.json.JSONArray; -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; +import de.danoeh.antennapod.net.sync.nextcloud.NextcloudSyncService; public class SyncService extends Worker { - private static final String PREF_NAME = "SyncService"; - private static final String PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp"; - private static final String PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp"; - private static final String PREF_QUEUED_FEEDS_ADDED = "sync_added"; - private static final String PREF_QUEUED_FEEDS_REMOVED = "sync_removed"; - private static final String PREF_QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions"; - private static final String PREF_LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp"; - private static final String PREF_LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success"; - private static final String TAG = "SyncService"; + public static final String TAG = "SyncService"; private static final String WORK_ID_SYNC = "SyncServiceWorkId"; - private static final ReentrantLock lock = new ReentrantLock(); - private ISyncService syncServiceImpl; + private static boolean isCurrentlyActive = false; + private final SynchronizationQueueStorage synchronizationQueueStorage; public SyncService(@NonNull Context context, @NonNull WorkerParameters params) { super(context, params); + synchronizationQueueStorage = new SynchronizationQueueStorage(context); } @Override @NonNull public Result doWork() { - if (!GpodnetPreferences.loggedIn()) { + ISyncService activeSyncProvider = getActiveSyncProvider(); + if (activeSyncProvider == null) { return Result.success(); } - syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(), - GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(), - GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); - SharedPreferences.Editor prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .edit(); - prefs.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()).apply(); + + SynchronizationSettings.updateLastSynchronizationAttempt(); + setCurrentlyActive(true); try { - syncServiceImpl.login(); - EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions)); - syncSubscriptions(); - syncEpisodeActions(); - syncServiceImpl.logout(); + activeSyncProvider.login(); + syncSubscriptions(activeSyncProvider); + waitForDownloadServiceCompleted(); + syncEpisodeActions(activeSyncProvider); + activeSyncProvider.logout(); clearErrorNotifications(); EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_success)); - prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, true).apply(); + SynchronizationSettings.setLastSynchronizationAttemptSuccess(true); return Result.success(); - } catch (SyncServiceException e) { + } catch (Exception e) { EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_error)); - prefs.putBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false).apply(); + SynchronizationSettings.setLastSynchronizationAttemptSuccess(false); Log.e(TAG, Log.getStackTraceString(e)); - if (getRunAttemptCount() % 3 == 2) { - // Do not spam users with notification and retry before notifying - updateErrorNotification(e); - } - return Result.retry(); - } - } - - public static void clearQueue(Context context) { - executeLockedAsync(() -> - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0) - .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0) - .putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0) - .putString(PREF_QUEUED_EPISODE_ACTIONS, "[]") - .putString(PREF_QUEUED_FEEDS_ADDED, "[]") - .putString(PREF_QUEUED_FEEDS_REMOVED, "[]") - .apply()); - } - - public static void enqueueFeedAdded(Context context, String downloadUrl) { - if (!GpodnetPreferences.loggedIn()) { - return; - } - executeLockedAsync(() -> { - try { - SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]"); - JSONArray queue = new JSONArray(json); - queue.put(downloadUrl); - prefs.edit().putString(PREF_QUEUED_FEEDS_ADDED, queue.toString()).apply(); - } catch (JSONException e) { - e.printStackTrace(); - } - sync(context); - }); - } - public static void enqueueFeedRemoved(Context context, String downloadUrl) { - if (!GpodnetPreferences.loggedIn()) { - return; - } - executeLockedAsync(() -> { - try { - SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]"); - JSONArray queue = new JSONArray(json); - queue.put(downloadUrl); - prefs.edit().putString(PREF_QUEUED_FEEDS_REMOVED, queue.toString()).apply(); - } catch (JSONException e) { - e.printStackTrace(); + if (e instanceof SyncServiceException) { + if (getRunAttemptCount() % 3 == 2) { + // Do not spam users with notification and retry before notifying + updateErrorNotification(e); + } + return Result.retry(); + } else { + updateErrorNotification(e); + return Result.failure(); } - sync(context); - }); - } - - public static void enqueueEpisodeAction(Context context, EpisodeAction action) { - if (!GpodnetPreferences.loggedIn()) { - return; + } finally { + setCurrentlyActive(false); } - executeLockedAsync(() -> { - try { - SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_EPISODE_ACTIONS, "[]"); - JSONArray queue = new JSONArray(json); - queue.put(action.writeToJsonObject()); - prefs.edit().putString(PREF_QUEUED_EPISODE_ACTIONS, queue.toString()).apply(); - } catch (JSONException e) { - e.printStackTrace(); - } - sync(context); - }); } - public static void enqueueEpisodePlayed(Context context, FeedMedia media, boolean completed) { - if (!GpodnetPreferences.loggedIn()) { - return; - } - if (media.getItem() == null) { - return; - } - if (media.getStartPosition() < 0 || (!completed && media.getStartPosition() >= media.getPosition())) { - return; - } - EpisodeAction action = new EpisodeAction.Builder(media.getItem(), EpisodeAction.PLAY) - .currentTimestamp() - .started(media.getStartPosition() / 1000) - .position((completed ? media.getDuration() : media.getPosition()) / 1000) - .total(media.getDuration() / 1000) - .build(); - SyncService.enqueueEpisodeAction(context, action); + private static void setCurrentlyActive(boolean active) { + isCurrentlyActive = active; } public static void sync(Context context) { OneTimeWorkRequest workRequest = getWorkRequest().build(); WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); - EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started)); } public static void syncImmediately(Context context) { @@ -207,127 +120,27 @@ public class SyncService extends Worker { .setInitialDelay(0L, TimeUnit.SECONDS) .build(); WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); - EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started)); } public static void fullSync(Context context) { - executeLockedAsync(() -> { - context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0) - .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0) - .putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0) - .apply(); - + LockingAsyncExecutor.executeLockedAsync(() -> { + SynchronizationSettings.resetTimestamps(); OneTimeWorkRequest workRequest = getWorkRequest() .setInitialDelay(0L, TimeUnit.SECONDS) .build(); WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_SYNC, ExistingWorkPolicy.REPLACE, workRequest); - EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started)); }); } - private static OneTimeWorkRequest.Builder getWorkRequest() { - Constraints.Builder constraints = new Constraints.Builder(); - if (UserPreferences.isAllowMobileFeedRefresh()) { - constraints.setRequiredNetworkType(NetworkType.CONNECTED); - } else { - constraints.setRequiredNetworkType(NetworkType.UNMETERED); - } - - return new OneTimeWorkRequest.Builder(SyncService.class) - .setConstraints(constraints.build()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES) - .setInitialDelay(5L, TimeUnit.SECONDS); // Give it some time, so other actions can be queued - } - - /** - * Take the lock and execute runnable (to prevent changes to preferences being lost when enqueueing while sync is - * in progress). If the lock is free, the runnable is directly executed in the calling thread to prevent overhead. - */ - private static void executeLockedAsync(Runnable runnable) { - if (lock.tryLock()) { - try { - runnable.run(); - } finally { - lock.unlock(); - } - } else { - Completable.fromRunnable(() -> { - lock.lock(); - try { - runnable.run(); - } finally { - lock.unlock(); - } - }).subscribeOn(Schedulers.io()) - .subscribe(); - } - } - - public static boolean isLastSyncSuccessful(Context context) { - return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getBoolean(PREF_LAST_SYNC_ATTEMPT_SUCCESS, false); - } - - public static long getLastSyncAttempt(Context context) { - return context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, 0); - } - - private List<EpisodeAction> getQueuedEpisodeActions() { - ArrayList<EpisodeAction> actions = new ArrayList<>(); - try { - SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_EPISODE_ACTIONS, "[]"); - JSONArray queue = new JSONArray(json); - for (int i = 0; i < queue.length(); i++) { - actions.add(EpisodeAction.readFromJsonObject(queue.getJSONObject(i))); - } - } catch (JSONException e) { - e.printStackTrace(); - } - return actions; - } - - private List<String> getQueuedRemovedFeeds() { - ArrayList<String> actions = new ArrayList<>(); - try { - SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_FEEDS_REMOVED, "[]"); - JSONArray queue = new JSONArray(json); - for (int i = 0; i < queue.length(); i++) { - actions.add(queue.getString(i)); - } - } catch (JSONException e) { - e.printStackTrace(); - } - return actions; - } - - private List<String> getQueuedAddedFeeds() { - ArrayList<String> actions = new ArrayList<>(); - try { - SharedPreferences prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); - String json = prefs.getString(PREF_QUEUED_FEEDS_ADDED, "[]"); - JSONArray queue = new JSONArray(json); - for (int i = 0; i < queue.length(); i++) { - actions.add(queue.getString(i)); - } - } catch (JSONException e) { - e.printStackTrace(); - } - return actions; - } - - private void syncSubscriptions() throws SyncServiceException { - final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0); + private void syncSubscriptions(ISyncService syncServiceImpl) throws SyncServiceException { + final long lastSync = SynchronizationSettings.getLastSubscriptionSynchronizationTimestamp(); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_subscriptions)); final List<String> localSubscriptions = DBReader.getFeedListDownloadUrls(); SubscriptionChanges subscriptionChanges = syncServiceImpl.getSubscriptionChanges(lastSync); long newTimeStamp = subscriptionChanges.getTimestamp(); - List<String> queuedRemovedFeeds = getQueuedRemovedFeeds(); - List<String> queuedAddedFeeds = getQueuedAddedFeeds(); + List<String> queuedRemovedFeeds = synchronizationQueueStorage.getQueuedRemovedFeeds(); + List<String> queuedAddedFeeds = synchronizationQueueStorage.getQueuedAddedFeeds(); Log.d(TAG, "Downloaded subscription changes: " + subscriptionChanges); for (String downloadUrl : subscriptionChanges.getAdded()) { @@ -359,26 +172,33 @@ public class SyncService extends Worker { Log.d(TAG, "Added: " + StringUtils.join(queuedAddedFeeds, ", ")); Log.d(TAG, "Removed: " + StringUtils.join(queuedRemovedFeeds, ", ")); - lock.lock(); + LockingAsyncExecutor.lock.lock(); try { UploadChangesResponse uploadResponse = syncServiceImpl .uploadSubscriptionChanges(queuedAddedFeeds, queuedRemovedFeeds); - getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putString(PREF_QUEUED_FEEDS_ADDED, "[]").apply(); - getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putString(PREF_QUEUED_FEEDS_REMOVED, "[]").apply(); + synchronizationQueueStorage.clearFeedQueues(); newTimeStamp = uploadResponse.timestamp; } finally { - lock.unlock(); + LockingAsyncExecutor.lock.unlock(); + } + } + SynchronizationSettings.setLastSubscriptionSynchronizationAttemptTimestamp(newTimeStamp); + } + + private void waitForDownloadServiceCompleted() { + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_wait_for_downloads)); + try { + while (DownloadRequester.getInstance().isDownloadingFeeds()) { + //noinspection BusyWait + Thread.sleep(1000); } + } catch (InterruptedException e) { + e.printStackTrace(); } - getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putLong(PREF_LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply(); } - private void syncEpisodeActions() throws SyncServiceException { - final long lastSync = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) - .getLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0); + private void syncEpisodeActions(ISyncService syncServiceImpl) throws SyncServiceException { + final long lastSync = SynchronizationSettings.getLastEpisodeActionSynchronizationTimestamp(); EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_download)); EpisodeActionChanges getResponse = syncServiceImpl.getEpisodeActionChanges(lastSync); long newTimeStamp = getResponse.getTimestamp(); @@ -387,7 +207,7 @@ public class SyncService extends Worker { // upload local actions EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_episodes_upload)); - List<EpisodeAction> queuedEpisodeActions = getQueuedEpisodeActions(); + List<EpisodeAction> queuedEpisodeActions = synchronizationQueueStorage.getQueuedEpisodeActions(); if (lastSync == 0) { EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_upload_played)); List<FeedItem> readItems = DBReader.getPlayedItems(); @@ -407,24 +227,21 @@ public class SyncService extends Worker { } } if (queuedEpisodeActions.size() > 0) { - lock.lock(); + LockingAsyncExecutor.lock.lock(); try { Log.d(TAG, "Uploading " + queuedEpisodeActions.size() + " actions: " + StringUtils.join(queuedEpisodeActions, ", ")); UploadChangesResponse postResponse = syncServiceImpl.uploadEpisodeActions(queuedEpisodeActions); newTimeStamp = postResponse.timestamp; Log.d(TAG, "Upload episode response: " + postResponse); - getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putString(PREF_QUEUED_EPISODE_ACTIONS, "[]").apply(); + synchronizationQueueStorage.clearEpisodeActionQueue(); } finally { - lock.unlock(); + LockingAsyncExecutor.lock.unlock(); } } - getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE).edit() - .putLong(PREF_LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, newTimeStamp).apply(); + SynchronizationSettings.setLastEpisodeActionSynchronizationAttemptTimestamp(newTimeStamp); } - private synchronized void processEpisodeActions(List<EpisodeAction> remoteActions) { Log.d(TAG, "Processing " + remoteActions.size() + " actions"); if (remoteActions.size() == 0) { @@ -432,7 +249,8 @@ public class SyncService extends Worker { } Map<Pair<String, String>, EpisodeAction> playActionsToUpdate = EpisodeActionFilter - .getRemoteActionsOverridingLocalActions(remoteActions, getQueuedEpisodeActions()); + .getRemoteActionsOverridingLocalActions(remoteActions, + synchronizationQueueStorage.getQueuedEpisodeActions()); LongList queueToBeRemoved = new LongList(); List<FeedItem> updatedItems = new ArrayList<>(); for (EpisodeAction action : playActionsToUpdate.values()) { @@ -442,20 +260,24 @@ public class SyncService extends Worker { Log.i(TAG, "Unknown feed item: " + action); continue; } + if (feedItem.getMedia() == null) { + Log.i(TAG, "Feed item has no media: " + action); + continue; + } if (action.getAction() == EpisodeAction.NEW) { DBWriter.markItemPlayed(feedItem, FeedItem.UNPLAYED, true); continue; } - Log.d(TAG, "Most recent play action: " + action.toString()); - FeedMedia media = feedItem.getMedia(); - media.setPosition(action.getPosition() * 1000); + feedItem.getMedia().setPosition(action.getPosition() * 1000); if (FeedItemUtil.hasAlmostEnded(feedItem.getMedia())) { - Log.d(TAG, "Marking as played"); + Log.d(TAG, "Marking as played: " + action); feedItem.setPlayed(true); + feedItem.getMedia().setPosition(0); queueToBeRemoved.add(feedItem.getId()); + } else { + Log.d(TAG, "Setting position: " + action); } updatedItems.add(feedItem); - } DBWriter.removeQueueItem(getApplicationContext(), false, queueToBeRemoved.toArray()); DBReader.loadAdditionalFeedItemListData(updatedItems); @@ -469,7 +291,7 @@ public class SyncService extends Worker { nm.cancel(R.id.notification_gpodnet_sync_autherror); } - private void updateErrorNotification(SyncServiceException exception) { + private void updateErrorNotification(Exception exception) { if (!UserPreferences.gpodnetNotificationsEnabled()) { Log.d(TAG, "Skipping sync error notification because of user setting"); return; @@ -481,11 +303,13 @@ public class SyncService extends Worker { Intent intent = getApplicationContext().getPackageManager().getLaunchIntentForPackage( getApplicationContext().getPackageName()); PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(), - R.id.pending_intent_sync_error, intent, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_sync_error, intent, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); Notification notification = new NotificationCompat.Builder(getApplicationContext(), NotificationUtils.CHANNEL_ID_SYNC_ERROR) .setContentTitle(getApplicationContext().getString(R.string.gpodnetsync_error_title)) .setContentText(description) + .setStyle(new NotificationCompat.BigTextStyle().bigText(description)) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.ic_notification_sync_error) .setAutoCancel(true) @@ -495,4 +319,48 @@ public class SyncService extends Worker { .getSystemService(Context.NOTIFICATION_SERVICE); nm.notify(R.id.notification_gpodnet_sync_error, notification); } + + private static OneTimeWorkRequest.Builder getWorkRequest() { + Constraints.Builder constraints = new Constraints.Builder(); + if (UserPreferences.isAllowMobileFeedRefresh()) { + constraints.setRequiredNetworkType(NetworkType.CONNECTED); + } else { + constraints.setRequiredNetworkType(NetworkType.UNMETERED); + } + + OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(SyncService.class) + .setConstraints(constraints.build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES); + + if (isCurrentlyActive) { + // Debounce: don't start sync again immediately after it was finished. + builder.setInitialDelay(2L, TimeUnit.MINUTES); + } else { + // Give it some time, so other possible actions can be queued. + builder.setInitialDelay(20L, TimeUnit.SECONDS); + EventBus.getDefault().postSticky(new SyncServiceEvent(R.string.sync_status_started)); + } + return builder; + } + + private ISyncService getActiveSyncProvider() { + String selectedSyncProviderKey = SynchronizationSettings.getSelectedSyncProviderKey(); + SynchronizationProviderViewData selectedService = SynchronizationProviderViewData + .fromIdentifier(selectedSyncProviderKey); + if (selectedService == null) { + return null; + } + switch (selectedService) { + case GPODDER_NET: + return new GpodnetService(AntennapodHttpClient.getHttpClient(), + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); + case NEXTCLOUD_GPODDER: + return new NextcloudSyncService(AntennapodHttpClient.getHttpClient(), + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getUsername(), + SynchronizationCredentials.getPassword()); + default: + return null; + } + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java new file mode 100644 index 000000000..e08bc66ad --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationCredentials.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.core.sync; + +import android.content.Context; +import android.content.SharedPreferences; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.sync.queue.SynchronizationQueueSink; + +/** + * Manages preferences for accessing gpodder.net service and other sync providers + */ +public class SynchronizationCredentials { + + private SynchronizationCredentials() { + } + + private static final String PREF_NAME = "gpodder.net"; + private static final String PREF_USERNAME = "de.danoeh.antennapod.preferences.gpoddernet.username"; + private static final String PREF_PASSWORD = "de.danoeh.antennapod.preferences.gpoddernet.password"; + private static final String PREF_DEVICEID = "de.danoeh.antennapod.preferences.gpoddernet.deviceID"; + private static final String PREF_HOSTNAME = "prefGpodnetHostname"; + + private static SharedPreferences getPreferences() { + return ClientConfig.applicationCallbacks.getApplicationInstance() + .getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + } + + public static String getUsername() { + return getPreferences().getString(PREF_USERNAME, null); + } + + public static void setUsername(String username) { + getPreferences().edit().putString(PREF_USERNAME, username).apply(); + } + + public static String getPassword() { + return getPreferences().getString(PREF_PASSWORD, null); + } + + public static void setPassword(String password) { + getPreferences().edit().putString(PREF_PASSWORD, password).apply(); + } + + public static String getDeviceID() { + return getPreferences().getString(PREF_DEVICEID, null); + } + + public static void setDeviceID(String deviceID) { + getPreferences().edit().putString(PREF_DEVICEID, deviceID).apply(); + } + + public static String getHosturl() { + return getPreferences().getString(PREF_HOSTNAME, null); + } + + public static void setHosturl(String value) { + getPreferences().edit().putString(PREF_HOSTNAME, value).apply(); + } + + public static synchronized void clear(Context context) { + setUsername(null); + setPassword(null); + setDeviceID(null); + SynchronizationQueueSink.clearQueue(context); + UserPreferences.setGpodnetNotificationsEnabled(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java new file mode 100644 index 000000000..cba713f60 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationProviderViewData.java @@ -0,0 +1,47 @@ +package de.danoeh.antennapod.core.sync; + +import de.danoeh.antennapod.core.R; + +public enum SynchronizationProviderViewData { + GPODDER_NET( + "GPODDER_NET", + R.string.gpodnet_description, + R.drawable.gpodder_icon + ), + NEXTCLOUD_GPODDER( + "NEXTCLOUD_GPODDER", + R.string.synchronization_summary_nextcloud, + R.drawable.nextcloud_logo + ); + + public static SynchronizationProviderViewData fromIdentifier(String provider) { + for (SynchronizationProviderViewData synchronizationProvider : SynchronizationProviderViewData.values()) { + if (synchronizationProvider.getIdentifier().equals(provider)) { + return synchronizationProvider; + } + } + return null; + } + + private final String identifier; + private final int iconResource; + private final int summaryResource; + + SynchronizationProviderViewData(String identifier, int summaryResource, int iconResource) { + this.identifier = identifier; + this.iconResource = iconResource; + this.summaryResource = summaryResource; + } + + public String getIdentifier() { + return identifier; + } + + public int getIconResource() { + return iconResource; + } + + public int getSummaryResource() { + return summaryResource; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java new file mode 100644 index 000000000..1a53ac0fb --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SynchronizationSettings.java @@ -0,0 +1,83 @@ +package de.danoeh.antennapod.core.sync; + +import android.content.Context; +import android.content.SharedPreferences; + +import de.danoeh.antennapod.core.ClientConfig; + +public class SynchronizationSettings { + + public static final String LAST_SYNC_ATTEMPT_TIMESTAMP = "last_sync_attempt_timestamp"; + private static final String NAME = "synchronization"; + private static final String SELECTED_SYNC_PROVIDER = "selected_sync_provider"; + private static final String LAST_SYNC_ATTEMPT_SUCCESS = "last_sync_attempt_success"; + private static final String LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP = "last_episode_actions_sync_timestamp"; + private static final String LAST_SUBSCRIPTION_SYNC_TIMESTAMP = "last_sync_timestamp"; + + public static boolean isProviderConnected() { + return getSelectedSyncProviderKey() != null; + } + + public static void resetTimestamps() { + getSharedPreferences().edit() + .putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0) + .putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0) + .putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0) + .apply(); + } + + public static boolean isLastSyncSuccessful() { + return getSharedPreferences().getBoolean(LAST_SYNC_ATTEMPT_SUCCESS, false); + } + + public static long getLastSyncAttempt() { + return getSharedPreferences().getLong(LAST_SYNC_ATTEMPT_TIMESTAMP, 0); + } + + public static void setSelectedSyncProvider(SynchronizationProviderViewData provider) { + getSharedPreferences() + .edit() + .putString(SELECTED_SYNC_PROVIDER, provider == null ? null : provider.getIdentifier()) + .apply(); + } + + public static String getSelectedSyncProviderKey() { + return getSharedPreferences().getString(SELECTED_SYNC_PROVIDER, null); + } + + public static void updateLastSynchronizationAttempt() { + getSharedPreferences().edit() + .putLong(LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()) + .apply(); + } + + public static void setLastSynchronizationAttemptSuccess(boolean isSuccess) { + getSharedPreferences().edit() + .putBoolean(LAST_SYNC_ATTEMPT_SUCCESS, isSuccess) + .apply(); + } + + public static long getLastSubscriptionSynchronizationTimestamp() { + return getSharedPreferences().getLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, 0); + } + + public static void setLastSubscriptionSynchronizationAttemptTimestamp(long newTimeStamp) { + getSharedPreferences().edit() + .putLong(LAST_SUBSCRIPTION_SYNC_TIMESTAMP, newTimeStamp).apply(); + } + + public static long getLastEpisodeActionSynchronizationTimestamp() { + return getSharedPreferences() + .getLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, 0); + } + + public static void setLastEpisodeActionSynchronizationAttemptTimestamp(long timestamp) { + getSharedPreferences().edit() + .putLong(LAST_EPISODE_ACTIONS_SYNC_TIMESTAMP, timestamp).apply(); + } + + private static SharedPreferences getSharedPreferences() { + return ClientConfig.applicationCallbacks.getApplicationInstance() + .getSharedPreferences(NAME, Context.MODE_PRIVATE); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java new file mode 100644 index 000000000..445faf60f --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueSink.java @@ -0,0 +1,67 @@ +package de.danoeh.antennapod.core.sync.queue; + +import android.content.Context; + +import de.danoeh.antennapod.core.sync.LockingAsyncExecutor; +import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; + +public class SynchronizationQueueSink { + + public static void clearQueue(Context context) { + LockingAsyncExecutor.executeLockedAsync(new SynchronizationQueueStorage(context)::clearQueue); + } + + public static void enqueueFeedAddedIfSynchronizationIsActive(Context context, String downloadUrl) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueFeedAdded(downloadUrl); + SyncService.sync(context); + }); + } + + public static void enqueueFeedRemovedIfSynchronizationIsActive(Context context, String downloadUrl) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueFeedRemoved(downloadUrl); + SyncService.sync(context); + }); + } + + public static void enqueueEpisodeActionIfSynchronizationIsActive(Context context, EpisodeAction action) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + LockingAsyncExecutor.executeLockedAsync(() -> { + new SynchronizationQueueStorage(context).enqueueEpisodeAction(action); + SyncService.sync(context); + }); + } + + public static void enqueueEpisodePlayedIfSynchronizationIsActive(Context context, FeedMedia media, + boolean completed) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + if (media.getItem() == null) { + return; + } + if (media.getStartPosition() < 0 || (!completed && media.getStartPosition() >= media.getPosition())) { + return; + } + EpisodeAction action = new EpisodeAction.Builder(media.getItem(), EpisodeAction.PLAY) + .currentTimestamp() + .started(media.getStartPosition() / 1000) + .position((completed ? media.getDuration() : media.getPosition()) / 1000) + .total(media.getDuration() / 1000) + .build(); + enqueueEpisodeActionIfSynchronizationIsActive(context, action); + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java new file mode 100644 index 000000000..5c6d58fe3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/queue/SynchronizationQueueStorage.java @@ -0,0 +1,140 @@ +package de.danoeh.antennapod.core.sync.queue; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.util.ArrayList; + +import de.danoeh.antennapod.core.sync.SynchronizationSettings; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; + +public class SynchronizationQueueStorage { + + private static final String NAME = "synchronization"; + private static final String QUEUED_EPISODE_ACTIONS = "sync_queued_episode_actions"; + private static final String QUEUED_FEEDS_REMOVED = "sync_removed"; + private static final String QUEUED_FEEDS_ADDED = "sync_added"; + private final SharedPreferences sharedPreferences; + + public SynchronizationQueueStorage(Context context) { + this.sharedPreferences = context.getSharedPreferences(NAME, Context.MODE_PRIVATE); + } + + public ArrayList<EpisodeAction> getQueuedEpisodeActions() { + ArrayList<EpisodeAction> actions = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_EPISODE_ACTIONS, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + actions.add(EpisodeAction.readFromJsonObject(queue.getJSONObject(i))); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return actions; + } + + public ArrayList<String> getQueuedRemovedFeeds() { + ArrayList<String> removedFeedUrls = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_FEEDS_REMOVED, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + removedFeedUrls.add(queue.getString(i)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return removedFeedUrls; + + } + + public ArrayList<String> getQueuedAddedFeeds() { + ArrayList<String> addedFeedUrls = new ArrayList<>(); + try { + String json = getSharedPreferences() + .getString(QUEUED_FEEDS_ADDED, "[]"); + JSONArray queue = new JSONArray(json); + for (int i = 0; i < queue.length(); i++) { + addedFeedUrls.add(queue.getString(i)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + return addedFeedUrls; + } + + public void clearEpisodeActionQueue() { + getSharedPreferences().edit() + .putString(QUEUED_EPISODE_ACTIONS, "[]").apply(); + + } + + public void clearFeedQueues() { + getSharedPreferences().edit() + .putString(QUEUED_FEEDS_ADDED, "[]") + .putString(QUEUED_FEEDS_REMOVED, "[]") + .apply(); + } + + protected void clearQueue() { + SynchronizationSettings.resetTimestamps(); + getSharedPreferences().edit() + .putString(QUEUED_EPISODE_ACTIONS, "[]") + .putString(QUEUED_FEEDS_ADDED, "[]") + .putString(QUEUED_FEEDS_REMOVED, "[]") + .apply(); + + } + + protected void enqueueFeedAdded(String downloadUrl) { + SharedPreferences sharedPreferences = getSharedPreferences(); + String json = sharedPreferences + .getString(QUEUED_FEEDS_ADDED, "[]"); + try { + JSONArray queue = new JSONArray(json); + queue.put(downloadUrl); + sharedPreferences + .edit().putString(QUEUED_FEEDS_ADDED, queue.toString()).apply(); + + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + protected void enqueueFeedRemoved(String downloadUrl) { + SharedPreferences sharedPreferences = getSharedPreferences(); + String json = sharedPreferences.getString(QUEUED_FEEDS_REMOVED, "[]"); + try { + JSONArray queue = new JSONArray(json); + queue.put(downloadUrl); + sharedPreferences.edit().putString(QUEUED_FEEDS_REMOVED, queue.toString()) + .apply(); + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + protected void enqueueEpisodeAction(EpisodeAction action) { + SharedPreferences sharedPreferences = getSharedPreferences(); + String json = sharedPreferences.getString(QUEUED_EPISODE_ACTIONS, "[]"); + try { + JSONArray queue = new JSONArray(json); + queue.put(action.writeToJsonObject()); + sharedPreferences.edit().putString( + QUEUED_EPISODE_ACTIONS, queue.toString() + ).apply(); + } catch (JSONException jsonException) { + jsonException.printStackTrace(); + } + } + + private SharedPreferences getSharedPreferences() { + return sharedPreferences; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java index e5f60d64b..09161ca7b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemPermutors.java @@ -9,6 +9,7 @@ import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import de.danoeh.antennapod.model.feed.FeedItem; @@ -77,25 +78,22 @@ public class FeedItemPermutors { @NonNull private static Date pubDate(@Nullable FeedItem item) { - return (item != null && item.getPubDate() != null) ? - item.getPubDate() : new Date(0); + return (item != null && item.getPubDate() != null) ? item.getPubDate() : new Date(0); } @NonNull private static String itemTitle(@Nullable FeedItem item) { - return (item != null && item.getTitle() != null) ? - item.getTitle() : ""; + return (item != null && item.getTitle() != null) ? item.getTitle().toLowerCase(Locale.getDefault()) : ""; } private static int duration(@Nullable FeedItem item) { - return (item != null && item.getMedia() != null) ? - item.getMedia().getDuration() : 0; + return (item != null && item.getMedia() != null) ? item.getMedia().getDuration() : 0; } @NonNull private static String feedTitle(@Nullable FeedItem item) { - return (item != null && item.getFeed() != null && item.getFeed().getTitle() != null) ? - item.getFeed().getTitle() : ""; + return (item != null && item.getFeed() != null && item.getFeed().getTitle() != null) + ? item.getFeed().getTitle().toLowerCase(Locale.getDefault()) : ""; } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java index 12f1e98f9..4aeed734e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/NetworkUtils.java @@ -16,6 +16,8 @@ import java.io.File; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -30,6 +32,8 @@ import okhttp3.Request; import okhttp3.Response; public class NetworkUtils { + private static final String REGEX_PATTERN_IP_ADDRESS = "([0-9]{1,3}[\\.]){3}[0-9]{1,3}"; + private NetworkUtils(){} private static final String TAG = NetworkUtils.class.getSimpleName(); @@ -40,56 +44,23 @@ public class NetworkUtils { NetworkUtils.context = context; } - /** - * Returns true if the device is connected to Wi-Fi and the Wi-Fi filter for - * automatic downloads is disabled or the device is connected to a Wi-Fi - * network that is on the 'selected networks' list of the Wi-Fi filter for - * automatic downloads and false otherwise. - * */ - public static boolean autodownloadNetworkAvailable() { - ConnectivityManager cm = (ConnectivityManager) context - .getSystemService(Context.CONNECTIVITY_SERVICE); + public static boolean isAutoDownloadAllowed() { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo networkInfo = cm.getActiveNetworkInfo(); - if (networkInfo != null) { - if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { - Log.d(TAG, "Device is connected to Wi-Fi"); - if (networkInfo.isConnected()) { - if (!UserPreferences.isEnableAutodownloadWifiFilter()) { - Log.d(TAG, "Auto-dl filter is disabled"); - return true; - } else { - WifiManager wm = (WifiManager) context.getApplicationContext() - .getSystemService(Context.WIFI_SERVICE); - WifiInfo wifiInfo = wm.getConnectionInfo(); - List<String> selectedNetworks = Arrays - .asList(UserPreferences - .getAutodownloadSelectedNetworks()); - if (selectedNetworks.contains(Integer.toString(wifiInfo - .getNetworkId()))) { - Log.d(TAG, "Current network is on the selected networks list"); - return true; - } - } - } - } else if (networkInfo.getType() == ConnectivityManager.TYPE_ETHERNET) { - Log.d(TAG, "Device is connected to Ethernet"); - if (networkInfo.isConnected()) { - return true; - } + if (networkInfo == null) { + return false; + } + if (networkInfo.getType() == ConnectivityManager.TYPE_WIFI) { + if (UserPreferences.isEnableAutodownloadWifiFilter()) { + return isInAllowedWifiNetwork(); } else { - if (!UserPreferences.isAllowMobileAutoDownload()) { - Log.d(TAG, "Auto Download not enabled on Mobile"); - return false; - } - if (networkInfo.isRoaming()) { - Log.d(TAG, "Roaming on foreign network"); - return false; - } - return true; + return !isNetworkMetered(); } + } else if (networkInfo.getType() == ConnectivityManager.TYPE_ETHERNET) { + return true; + } else { + return UserPreferences.isAllowMobileAutoDownload() || !NetworkUtils.isNetworkRestricted(); } - Log.d(TAG, "Network for auto-dl is not available"); - return false; } public static boolean networkAvailable() { @@ -157,6 +128,12 @@ public class NetworkUtils { } } + private static boolean isInAllowedWifiNetwork() { + WifiManager wm = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + List<String> selectedNetworks = Arrays.asList(UserPreferences.getAutodownloadSelectedNetworks()); + return selectedNetworks.contains(Integer.toString(wm.getConnectionInfo().getNetworkId())); + } + /** * Returns the SSID of the wifi connection, or <code>null</code> if there is no wifi. */ @@ -169,6 +146,22 @@ public class NetworkUtils { return null; } + public static boolean wasDownloadBlocked(Throwable throwable) { + String message = throwable.getMessage(); + if (message != null) { + Pattern pattern = Pattern.compile(REGEX_PATTERN_IP_ADDRESS); + Matcher matcher = pattern.matcher(message); + if (matcher.find()) { + String ip = matcher.group(); + return ip.startsWith("127.") || ip.startsWith("0."); + } + } + if (throwable.getCause() != null) { + return wasDownloadBlocked(throwable.getCause()); + } + return false; + } + public static Single<Long> getFeedMediaSizeObservable(FeedMedia media) { return Single.create((SingleOnSubscribe<Long>) emitter -> { if (!NetworkUtils.isEpisodeHeadDownloadAllowed()) { diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtils.java deleted file mode 100644 index 813c6d0f7..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtils.java +++ /dev/null @@ -1,47 +0,0 @@ -package de.danoeh.antennapod.core.util; - -import java.util.concurrent.TimeUnit; - -/** - * This class calculates the proper rewind time after the pause and resume. - * <p> - * User might loose context if he/she pauses and resumes the media after longer time. - * Media file should be "rewinded" x seconds after user resumes the playback. - */ -public class RewindAfterPauseUtils { - private RewindAfterPauseUtils(){} - - public static final long ELAPSED_TIME_FOR_SHORT_REWIND = TimeUnit.MINUTES.toMillis(1); - public static final long ELAPSED_TIME_FOR_MEDIUM_REWIND = TimeUnit.HOURS.toMillis(1); - public static final long ELAPSED_TIME_FOR_LONG_REWIND = TimeUnit.DAYS.toMillis(1); - - public static final long SHORT_REWIND = TimeUnit.SECONDS.toMillis(3); - public static final long MEDIUM_REWIND = TimeUnit.SECONDS.toMillis(10); - public static final long LONG_REWIND = TimeUnit.SECONDS.toMillis(20); - - /** - * @param currentPosition current position in a media file in ms - * @param lastPlayedTime timestamp when was media paused - * @return new rewinded position for playback in milliseconds - */ - public static int calculatePositionWithRewind(int currentPosition, long lastPlayedTime) { - if (currentPosition > 0 && lastPlayedTime > 0) { - long elapsedTime = System.currentTimeMillis() - lastPlayedTime; - long rewindTime = 0; - - if (elapsedTime > ELAPSED_TIME_FOR_LONG_REWIND) { - rewindTime = LONG_REWIND; - } else if (elapsedTime > ELAPSED_TIME_FOR_MEDIUM_REWIND) { - rewindTime = MEDIUM_REWIND; - } else if (elapsedTime > ELAPSED_TIME_FOR_SHORT_REWIND) { - rewindTime = SHORT_REWIND; - } - - int newPosition = currentPosition - (int) rewindTime; - - return Math.max(newPosition, 0); - } else { - return currentPosition; - } - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java index c1c48f70d..34b9d294d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java @@ -5,10 +5,12 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; -import android.os.Build; -import androidx.core.content.FileProvider; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.core.app.ShareCompat; +import androidx.core.content.FileProvider; + import java.io.File; import java.util.List; @@ -24,11 +26,13 @@ public class ShareUtils { private ShareUtils() { } - public static void shareLink(Context context, String text) { - Intent i = new Intent(Intent.ACTION_SEND); - i.setType("text/plain"); - i.putExtra(Intent.EXTRA_TEXT, text); - context.startActivity(Intent.createChooser(i, context.getString(R.string.share_url_label))); + public static void shareLink(@NonNull Context context, @NonNull String text) { + Intent intent = new ShareCompat.IntentBuilder(context) + .setType("text/plain") + .setText(text) + .setChooserTitle(R.string.share_url_label) + .createChooserIntent(); + context.startActivity(intent); } public static void shareFeedlink(Context context, Feed feed) { @@ -75,21 +79,20 @@ public class ShareUtils { } public static void shareFeedItemFile(Context context, FeedMedia media) { - Intent i = new Intent(Intent.ACTION_SEND); - i.setType(media.getMime_type()); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(media.getMime_type()); Uri fileUri = FileProvider.getUriForFile(context, context.getString(R.string.provider_authority), new File(media.getLocalMediaUrl())); - i.putExtra(Intent.EXTRA_STREAM, fileUri); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { - List<ResolveInfo> resInfoList = context.getPackageManager() - .queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } + intent.putExtra(Intent.EXTRA_STREAM, fileUri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + Intent chooserIntent = Intent.createChooser(intent, context.getString(R.string.share_file_label)); + List<ResolveInfo> resInfoList = context.getPackageManager() + .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); } - context.startActivity(Intent.createChooser(i, context.getString(R.string.share_file_label))); + context.startActivity(chooserIntent); Log.e(TAG, "shareFeedItemFile called"); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java index 414b5c781..cf049ed80 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/StorageUtils.java @@ -1,7 +1,6 @@ package de.danoeh.antennapod.core.util; import android.app.Activity; -import android.os.Build; import android.os.StatFs; import android.util.Log; @@ -63,29 +62,15 @@ public class StorageUtils { */ public static long getFreeSpaceAvailable(String path) { StatFs stat = new StatFs(path); - long availableBlocks; - long blockSize; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - availableBlocks = stat.getAvailableBlocksLong(); - blockSize = stat.getBlockSizeLong(); - } else { - availableBlocks = stat.getAvailableBlocks(); - blockSize = stat.getBlockSize(); - } + long availableBlocks = stat.getAvailableBlocksLong(); + long blockSize = stat.getBlockSizeLong(); return availableBlocks * blockSize; } public static long getTotalSpaceAvailable(String path) { StatFs stat = new StatFs(path); - long blockCount; - long blockSize; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { - blockCount = stat.getBlockCountLong(); - blockSize = stat.getBlockSizeLong(); - } else { - blockCount = stat.getBlockCount(); - blockSize = stat.getBlockSize(); - } + long blockCount = stat.getBlockCountLong(); + long blockSize = stat.getBlockSizeLong(); return blockCount * blockSize; } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/download/AutoUpdateManager.java b/core/src/main/java/de/danoeh/antennapod/core/util/download/AutoUpdateManager.java index bb0a71744..dbad1f63e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/download/AutoUpdateManager.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/download/AutoUpdateManager.java @@ -1,9 +1,10 @@ package de.danoeh.antennapod.core.util.download; import android.content.Context; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.work.Constraints; import androidx.work.Data; import androidx.work.ExistingPeriodicWorkPolicy; @@ -17,6 +18,7 @@ import java.util.Arrays; import java.util.Calendar; import java.util.concurrent.TimeUnit; +import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.FeedUpdateWorker; import de.danoeh.antennapod.core.storage.DBTasks; @@ -70,7 +72,7 @@ public class AutoUpdateManager { Log.d(TAG, "Restarting update alarm."); Calendar now = Calendar.getInstance(); - Calendar alarm = (Calendar)now.clone(); + Calendar alarm = (Calendar) now.clone(); alarm.set(Calendar.HOUR_OF_DAY, hoursOfDay); alarm.set(Calendar.MINUTE, minute); if (alarm.before(now) || alarm.equals(now)) { @@ -121,8 +123,24 @@ public class AutoUpdateManager { Log.d(TAG, "Run auto update immediately in background."); if (!NetworkUtils.networkAvailable()) { Log.d(TAG, "Ignoring: No network connection."); - return; + } else if (NetworkUtils.isEpisodeDownloadAllowed()) { + startRefreshAllFeeds(context); + } else { + confirmMobileAllFeedsRefresh(context); } + } + + private static void confirmMobileAllFeedsRefresh(final Context context) { + AlertDialog.Builder builder = new AlertDialog.Builder(context) + .setTitle(R.string.feed_refresh_title) + .setMessage(R.string.confirm_mobile_feed_refresh_dialog_message) + .setPositiveButton(R.string.yes, + (dialog, which) -> startRefreshAllFeeds(context)) + .setNegativeButton(R.string.no, null); + builder.show(); + } + + private static void startRefreshAllFeeds(final Context context) { new Thread(() -> DBTasks.refreshAllFeeds( context.getApplicationContext(), true), "ManualRefreshAllFeeds").start(); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java index 7f4c1ceaf..549171c76 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java @@ -7,23 +7,24 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.ServiceConnection; -import android.media.MediaPlayer; import android.os.IBinder; import android.util.Log; import android.util.Pair; import android.view.SurfaceHolder; import androidx.annotation.NonNull; -import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.event.MessageEvent; -import de.danoeh.antennapod.core.event.ServiceEvent; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.event.playback.PlaybackServiceEvent; +import de.danoeh.antennapod.event.playback.SpeedChangedEvent; import de.danoeh.antennapod.model.playback.MediaType; import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -71,8 +72,8 @@ public abstract class PlaybackController { } @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(ServiceEvent event) { - if (event.action == ServiceEvent.Action.SERVICE_STARTED) { + public void onEventMainThread(PlaybackServiceEvent event) { + if (event.action == PlaybackServiceEvent.Action.SERVICE_STARTED) { init(); } } @@ -209,13 +210,6 @@ public abstract class PlaybackController { return; } switch (type) { - case PlaybackService.NOTIFICATION_TYPE_ERROR: - handleError(code); - break; - case PlaybackService.NOTIFICATION_TYPE_BUFFER_UPDATE: - float progress = ((float) code) / 100; - onBufferUpdate(progress); - break; case PlaybackService.NOTIFICATION_TYPE_RELOAD: if (playbackService == null && PlaybackService.isRunning) { bindToService(); @@ -226,21 +220,9 @@ public abstract class PlaybackController { onReloadNotification(intent.getIntExtra( PlaybackService.EXTRA_NOTIFICATION_CODE, -1)); break; - case PlaybackService.NOTIFICATION_TYPE_SLEEPTIMER_UPDATE: - onSleepTimerUpdate(); - break; - case PlaybackService.NOTIFICATION_TYPE_BUFFER_START: - onBufferStart(); - break; - case PlaybackService.NOTIFICATION_TYPE_BUFFER_END: - onBufferEnd(); - break; case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END: onPlaybackEnd(); break; - case PlaybackService.NOTIFICATION_TYPE_PLAYBACK_SPEED_CHANGE: - onPlaybackSpeedChange(); - break; } } @@ -248,24 +230,11 @@ public abstract class PlaybackController { public void onPositionObserverUpdate() {} - - public void onPlaybackSpeedChange() {} - /** * Called when the currently displayed information should be refreshed. */ public void onReloadNotification(int code) {} - public void onBufferStart() {} - - public void onBufferEnd() {} - - public void onBufferUpdate(float progress) {} - - public void onSleepTimerUpdate() {} - - public void handleError(int code) {} - public void onPlaybackEnd() {} /** @@ -276,10 +245,6 @@ public abstract class PlaybackController { Log.d(TAG, "status: " + status.toString()); checkMediaInfoLoaded(); switch (status) { - case ERROR: - EventBus.getDefault().post(new MessageEvent(activity.getString(R.string.player_error_msg))); - handleError(MediaPlayer.MEDIA_ERROR_UNKNOWN); - break; case PAUSED: onPositionObserverUpdate(); updatePlayButtonShowsPlay(true); @@ -458,6 +423,11 @@ public abstract class PlaybackController { public void seekTo(int time) { if (playbackService != null) { playbackService.seekTo(time); + } else if (getMedia() instanceof FeedMedia) { + FeedMedia media = (FeedMedia) getMedia(); + media.setPosition(time); + DBWriter.setFeedItem(media.getItem()); + EventBus.getDefault().post(new PlaybackPositionEvent(time, getMedia().getDuration())); } } @@ -482,7 +452,7 @@ public abstract class PlaybackController { if (playbackService != null) { playbackService.setSpeed(speed); } else { - onPlaybackSpeedChange(); + EventBus.getDefault().post(new SpeedChangedEvent(speed)); } } @@ -555,20 +525,6 @@ public abstract class PlaybackController { } } - /** - * Move service into INITIALIZED state if it's paused to save bandwidth - */ - public void reinitServiceIfPaused() { - if (playbackService != null - && playbackService.isStreaming() - && !PlaybackService.isCasting() - && (playbackService.getStatus() == PlayerStatus.PAUSED || - (playbackService.getStatus() == PlayerStatus.PREPARING && - !playbackService.isStartWhenPrepared()))) { - playbackService.reinit(); - } - } - public boolean isStreaming() { return playbackService != null && playbackService.isStreaming(); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java index cecd4b3b6..2762fb9fe 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java +++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java @@ -7,6 +7,7 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.KeyEvent; @@ -19,15 +20,16 @@ import com.bumptech.glide.request.RequestOptions; import java.util.concurrent.TimeUnit; import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.model.playback.MediaType; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; import de.danoeh.antennapod.core.receiver.PlayerWidget; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; import de.danoeh.antennapod.core.util.TimeSpeedConverter; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlayerStatus; import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter; @@ -67,9 +69,6 @@ public abstract class WidgetUpdater { if (!PlayerWidget.isEnabled(context) || widgetState == null) { return; } - ComponentName playerWidget = new ComponentName(context, PlayerWidget.class); - AppWidgetManager manager = AppWidgetManager.getInstance(context); - int[] widgetIds = manager.getAppWidgetIds(playerWidget); PendingIntent startMediaPlayer; if (widgetState.media != null && widgetState.media.getMediaType() == MediaType.VIDEO @@ -156,36 +155,36 @@ public abstract class WidgetUpdater { views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_widget_play); } - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - for (int id : widgetIds) { - Bundle options = manager.getAppWidgetOptions(id); - SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); - int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); - int columns = getCellsForSize(minWidth); - if (columns < 3) { - views.setViewVisibility(R.id.layout_center, View.INVISIBLE); - } else { - views.setViewVisibility(R.id.layout_center, View.VISIBLE); - } - boolean showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false); - boolean showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false); - boolean showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false); - - if (showRewind || showSkip || showFastForward) { - views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE); - views.setInt(R.id.butPlay, "setVisibility", View.GONE); - views.setInt(R.id.butRew, "setVisibility", showRewind ? View.VISIBLE : View.GONE); - views.setInt(R.id.butFastForward, "setVisibility", showFastForward ? View.VISIBLE : View.GONE); - views.setInt(R.id.butSkip, "setVisibility", showSkip ? View.VISIBLE : View.GONE); - } - - int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR); - views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor); + ComponentName playerWidget = new ComponentName(context, PlayerWidget.class); + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] widgetIds = manager.getAppWidgetIds(playerWidget); - manager.updateAppWidget(id, views); + for (int id : widgetIds) { + Bundle options = manager.getAppWidgetOptions(id); + SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); + int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + int columns = getCellsForSize(minWidth); + if (columns < 3) { + views.setViewVisibility(R.id.layout_center, View.INVISIBLE); + } else { + views.setViewVisibility(R.id.layout_center, View.VISIBLE); } - } else { - manager.updateAppWidget(playerWidget, views); + boolean showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false); + boolean showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false); + boolean showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false); + + if (showRewind || showSkip || showFastForward) { + views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE); + views.setInt(R.id.butPlay, "setVisibility", View.GONE); + views.setInt(R.id.butRew, "setVisibility", showRewind ? View.VISIBLE : View.GONE); + views.setInt(R.id.butFastForward, "setVisibility", showFastForward ? View.VISIBLE : View.GONE); + views.setInt(R.id.butSkip, "setVisibility", showSkip ? View.VISIBLE : View.GONE); + } + + int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR); + views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor); + + manager.updateAppWidget(id, views); } } @@ -212,18 +211,21 @@ public abstract class WidgetUpdater { startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); - return PendingIntent.getBroadcast(context, eventCode, startingIntent, 0); + return PendingIntent.getBroadcast(context, eventCode, startingIntent, + (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } private static String getProgressString(int position, int duration, float speed) { - if (position >= 0 && duration > 0) { - TimeSpeedConverter converter = new TimeSpeedConverter(speed); - position = converter.convert(position); - duration = converter.convert(duration); - return Converter.getDurationStringLong(position) + " / " - + Converter.getDurationStringLong(duration); - } else { + if (position < 0 || duration <= 0) { return null; } + TimeSpeedConverter converter = new TimeSpeedConverter(speed); + if (UserPreferences.shouldShowRemainingTime()) { + return Converter.getDurationStringLong(converter.convert(position)) + " / -" + + Converter.getDurationStringLong(converter.convert(Math.max(0, duration - position))); + } else { + return Converter.getDurationStringLong(converter.convert(position)) + " / " + + Converter.getDurationStringLong(converter.convert(duration)); + } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java index b14fb3b0b..325c508c5 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java @@ -6,9 +6,9 @@ import androidx.annotation.NonNull; import androidx.core.app.SafeJobIntentService; import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.core.util.playback.PlayableUtils; +import de.danoeh.antennapod.playback.base.PlayerStatus; public class WidgetUpdaterJobService extends SafeJobIntentService { private static final int JOB_ID = -17001; diff --git a/core/src/main/res/drawable-nodpi/nextcloud_logo.png b/core/src/main/res/drawable-nodpi/nextcloud_logo.png Binary files differnew file mode 100644 index 000000000..2164e37fb --- /dev/null +++ b/core/src/main/res/drawable-nodpi/nextcloud_logo.png diff --git a/core/src/main/res/drawable/ic_download_black.xml b/core/src/main/res/drawable/ic_download_black.xml new file mode 100644 index 000000000..eba137a59 --- /dev/null +++ b/core/src/main/res/drawable/ic_download_black.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="#000000" + android:pathData="M18,15v3H6v-3H4v3c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-3H18zM17,11l-1.41,-1.41L13,12.17V4h-2v8.17L8.41,9.59L7,11l5,5L17,11z"/> +</vector> diff --git a/core/src/main/res/drawable/ic_tag.xml b/core/src/main/res/drawable/ic_tag.xml new file mode 100644 index 000000000..95db04e93 --- /dev/null +++ b/core/src/main/res/drawable/ic_tag.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="?attr/action_icon_color" + android:pathData="M21.41,11.58L12.41,2.58A2,2 0,0 0,11 2H4A2,2 0,0 0,2 4V11A2,2 0,0 0,2.59 12.42L11.59,21.42A2,2 0,0 0,13 22A2,2 0,0 0,14.41 21.41L21.41,14.41A2,2 0,0 0,22 13A2,2 0,0 0,21.41 11.58M13,20L4,11V4H11L20,13M6.5,5A1.5,1.5 0,1 1,5 6.5A1.5,1.5 0,0 1,6.5 5Z"/> +</vector> diff --git a/core/src/main/res/layout/player_widget.xml b/core/src/main/res/layout/player_widget.xml index a70e98f0f..60d40e6b5 100644 --- a/core/src/main/res/layout/player_widget.xml +++ b/core/src/main/res/layout/player_widget.xml @@ -19,7 +19,6 @@ android:layout_width="@android:dimen/app_icon_size" android:layout_height="match_parent" android:contentDescription="@string/play_label" - android:layout_alignParentRight="true" android:layout_alignParentEnd="true" android:layout_margin="12dp" android:background="?android:attr/selectableItemBackground" @@ -31,9 +30,7 @@ android:id="@+id/layout_left" android:layout_width="match_parent" android:layout_height="match_parent" - android:layout_alignParentLeft="true" android:layout_alignParentStart="true" - android:layout_toLeftOf="@id/butPlay" android:layout_toStartOf="@id/butPlay" android:background="@android:color/transparent" android:gravity="fill_horizontal" @@ -97,7 +94,6 @@ android:layout_height="36dp" android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/rewind_label" - android:layout_marginRight="2dp" android:layout_marginEnd="2dp" android:scaleType="fitXY" android:src="@drawable/ic_widget_fast_rewind" /> @@ -108,7 +104,6 @@ android:layout_height="36dp" android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/play_label" - android:layout_marginRight="2dp" android:layout_marginEnd="2dp" android:scaleType="fitXY" android:src="@drawable/ic_widget_play" /> @@ -119,7 +114,6 @@ android:layout_height="36dp" android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/fast_forward_label" - android:layout_marginRight="2dp" android:layout_marginEnd="2dp" android:scaleType="fitXY" android:src="@drawable/ic_widget_fast_forward" /> @@ -130,7 +124,6 @@ android:layout_height="36dp" android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/skip_episode_label" - android:layout_marginRight="2dp" android:layout_marginEnd="2dp" android:scaleType="fitXY" android:src="@drawable/ic_widget_skip" /> diff --git a/core/src/main/res/values-land/dimens.xml b/core/src/main/res/values-land/dimens.xml deleted file mode 100644 index 73b2b2e98..000000000 --- a/core/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <dimen name="media_router_controller_playback_control_start_padding">@dimen/media_router_controller_playback_control_horizontal_spacing</dimen> -</resources> diff --git a/core/src/main/res/values-v21/styles.xml b/core/src/main/res/values-v21/styles.xml index 996b16f5e..349ca3213 100644 --- a/core/src/main/res/values-v21/styles.xml +++ b/core/src/main/res/values-v21/styles.xml @@ -4,14 +4,17 @@ <item name="android:windowContentTransitions">true</item> <!-- To make icons visible --> <item name="android:statusBarColor">@color/grey600</item> + <item name="android:navigationBarColor">@color/grey600</item> </style> <style name="Theme.AntennaPod.Dark" parent="Theme.Base.AntennaPod.Dark"> <item name="android:windowContentTransitions">true</item> <item name="android:statusBarColor">@color/background_darktheme</item> + <item name="android:navigationBarColor">@color/background_darktheme</item> </style> <style name="Theme.AntennaPod.TrueBlack" parent="Theme.Base.AntennaPod.TrueBlack"> <item name="android:statusBarColor">@color/black</item> + <item name="android:navigationBarColor">@color/black</item> </style> </resources>
\ No newline at end of file diff --git a/core/src/main/res/values-v23/styles.xml b/core/src/main/res/values-v23/styles.xml index fd339a071..dde8e41ae 100644 --- a/core/src/main/res/values-v23/styles.xml +++ b/core/src/main/res/values-v23/styles.xml @@ -4,15 +4,18 @@ <item name="android:windowContentTransitions">true</item> <item name="android:statusBarColor">@color/background_light</item> <item name="android:windowLightStatusBar">true</item> + <item name="android:navigationBarColor">@color/background_light</item> </style> <style name="Theme.AntennaPod.Dark" parent="Theme.Base.AntennaPod.Dark"> <item name="android:windowContentTransitions">true</item> <item name="android:statusBarColor">@color/background_darktheme</item> <item name="android:windowLightStatusBar">false</item> + <item name="android:navigationBarColor">@color/background_darktheme</item> </style> <style name="Theme.AntennaPod.TrueBlack" parent="Theme.Base.AntennaPod.TrueBlack"> <item name="android:statusBarColor">@color/black</item> + <item name="android:navigationBarColor">@color/black</item> </style> </resources>
\ No newline at end of file diff --git a/core/src/main/res/values-v27/styles.xml b/core/src/main/res/values-v27/styles.xml new file mode 100644 index 000000000..a28090155 --- /dev/null +++ b/core/src/main/res/values-v27/styles.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Theme.AntennaPod.Light" parent="Theme.Base.AntennaPod.Light"> + <item name="android:windowContentTransitions">true</item> + <item name="android:statusBarColor">@color/background_light</item> + <item name="android:windowLightStatusBar">true</item> + <item name="android:navigationBarColor">@color/background_light</item> + <item name="android:navigationBarDividerColor">@color/navigation_bar_divider_light</item> + <item name="android:windowLightNavigationBar">true</item> + </style> +</resources>
\ No newline at end of file diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml index 97b677362..ba4d48219 100644 --- a/core/src/main/res/values/arrays.xml +++ b/core/src/main/res/values/arrays.xml @@ -135,58 +135,6 @@ <item>-2</item> </string-array> - <string-array name="playback_speed_values"> - <item>0.50</item> - <item>0.60</item> - <item>0.70</item> - <item>0.75</item> - <item>0.80</item> - <item>0.85</item> - <item>0.90</item> - <item>0.95</item> - <item>1.00</item> - <item>1.05</item> - <item>1.10</item> - <item>1.15</item> - <item>1.20</item> - <item>1.25</item> - <item>1.30</item> - <item>1.35</item> - <item>1.40</item> - <item>1.45</item> - <item>1.50</item> - <item>1.55</item> - <item>1.60</item> - <item>1.65</item> - <item>1.70</item> - <item>1.75</item> - <item>1.80</item> - <item>1.85</item> - <item>1.90</item> - <item>1.95</item> - <item>2.00</item> - <item>2.10</item> - <item>2.20</item> - <item>2.30</item> - <item>2.40</item> - <item>2.50</item> - <item>2.60</item> - <item>2.70</item> - <item>2.80</item> - <item>2.90</item> - <item>3.00</item> - <item>3.10</item> - <item>3.20</item> - <item>3.30</item> - <item>3.40</item> - <item>3.50</item> - <item>3.60</item> - <item>3.70</item> - <item>3.80</item> - <item>3.90</item> - <item>4.00</item> - </string-array> - <string-array name="theme_options"> <item>@string/pref_theme_title_use_system</item> <item>@string/pref_theme_title_light</item> diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml index 760044854..859b64367 100644 --- a/core/src/main/res/values/colors.xml +++ b/core/src/main/res/values/colors.xml @@ -25,6 +25,7 @@ <color name="non_square_icon_background">#22777777</color> <color name="seek_background_light">#90000000</color> <color name="seek_background_dark">#905B5B5B</color> + <color name="navigation_bar_divider_light">#1F000000</color> <color name="accent_light">#0078C2</color> <color name="accent_dark">#3D8BFF</color> diff --git a/core/src/main/res/values/dimens.xml b/core/src/main/res/values/dimens.xml index d1e200d1d..4b2247492 100644 --- a/core/src/main/res/values/dimens.xml +++ b/core/src/main/res/values/dimens.xml @@ -1,6 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <resources> - <dimen name="widget_margin">0dp</dimen> <dimen name="external_player_height">64dp</dimen> <dimen name="text_size_micro">12sp</dimen> @@ -28,11 +27,5 @@ <dimen name="audioplayer_playercontrols_length_big">64dp</dimen> <dimen name="audioplayer_playercontrols_margin">12dp</dimen> - <dimen name="media_router_controller_playback_control_vertical_padding">16dp</dimen> - <dimen name="media_router_controller_playback_control_horizontal_spacing">12dp</dimen> - <dimen name="media_router_controller_playback_control_start_padding">24dp</dimen> - <dimen name="media_router_controller_bottom_margin">8dp</dimen> - <dimen name="nav_drawer_max_screen_size">480dp</dimen> - </resources> diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index e407b700a..59b335bc8 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -45,7 +45,6 @@ <!-- Statistics fragment --> <string name="total_time_listened_to_podcasts">Total time of episodes played:</string> - <string name="statistics_details_dialog">%1$d out of %2$d episodes started.\n\nPlayed %3$s out of %4$s.</string> <string name="statistics_mode">Statistics mode</string> <string name="statistics_mode_normal">Calculate duration that was actually played. Playing twice is counted twice, while marking as played is not counted</string> <string name="statistics_mode_count_all">Sum up all episodes marked as played</string> @@ -188,6 +187,7 @@ <item quantity="other">%d subscriptions updated.</item> </plurals> <string name="add_to_folder">Add to folder</string> + <string name="confirm_mobile_feed_refresh_dialog_message">Downloading episodes over mobile data connection is disabled in the settings.\n\nDo you still want to refresh all podcasts over mobile data?</string> <!-- actions on feeditems --> <string name="download_label">Download</string> @@ -263,8 +263,8 @@ <string name="download_error_forbidden">The podcast host\'s server refuses to respond.</string> <string name="download_canceled_msg">Download canceled</string> <string name="download_error_wrong_size">The server connection was lost before completing the download</string> - <string name="download_error_blocked">The download was blocked by another app on your device.</string> - <string name="download_error_certificate">Unable to establish a secure connection. This can mean that another app on your device blocked the download, or that something is wrong with the server certificates.</string> + <string name="download_error_blocked">The download was blocked by another app on your device (like a VPN or ad blocker).</string> + <string name="download_error_certificate">Unable to establish a secure connection. This can mean that another app on your device (like a VPN or an ad blocker) blocked the download, or that something is wrong with the server certificates.</string> <string name="download_report_title">Downloads completed with error(s)</string> <string name="auto_download_report_title">Auto-downloads completed</string> <string name="download_error_io_error">IO Error</string> @@ -294,7 +294,6 @@ <string name="confirm_mobile_download_dialog_enable_temporarily">Allow temporarily</string> <!-- Mediaplayer messages --> - <string name="player_error_msg">Error!</string> <string name="playback_error_server_died">Server died</string> <string name="playback_error_unsupported">Unsupported media type</string> <string name="playback_error_timeout">Operation timed out</string> @@ -356,7 +355,7 @@ <string name="storage_sum">Episode auto delete, Import, Export</string> <string name="project_pref">Project</string> <string name="synchronization_pref">Synchronization</string> - <string name="synchronization_sum">Synchronize with other devices using gpodder.net</string> + <string name="synchronization_sum">Synchronize with other devices</string> <string name="automation">Automation</string> <string name="download_pref_details">Details</string> <string name="import_export_pref">Import/Export</string> @@ -447,17 +446,20 @@ <string name="pref_theme_title_dark">Dark</string> <string name="pref_theme_title_trueblack">Black (AMOLED ready)</string> <string name="pref_episode_cache_unlimited">Unlimited</string> - <string name="pref_gpodnet_authenticate_title">Login</string> - <string name="pref_gpodnet_authenticate_sum">Login with your gpodder.net account in order to sync your subscriptions.</string> - <string name="pref_gpodnet_logout_title">Logout</string> - <string name="pref_gpodnet_logout_toast">Logout was successful</string> + <string name="synchronization_logout">Logout</string> + <string name="pref_synchronization_logout_toast">Logout was successful</string> <string name="pref_gpodnet_setlogin_information_title">Change login information</string> <string name="pref_gpodnet_setlogin_information_sum">Change the login information for your gpodder.net account.</string> - <string name="pref_gpodnet_sync_changes_title">Synchronize now</string> - <string name="pref_gpodnet_sync_changes_sum">Sync subscription and episode state changes with gpodder.net.</string> - <string name="pref_gpodnet_full_sync_title">Force full synchronization</string> - <string name="pref_gpodnet_full_sync_sum">Sync all subscriptions and episode states with gpodder.net.</string> - <string name="pref_gpodnet_login_status"><![CDATA[Logged in as <i>%1$s</i> with device <i>%2$s</i>]]></string> + <string name="synchronization_sync_changes_title">Synchronize now</string> + <string name="synchronization_full_sync_title">Force full synchronization</string> + <string name="synchronization_login_status"><![CDATA[Logged in as <i>%1$s</i> on <i>%2$s</i>. <br/><br/>You can choose your synchronization provider again once you have logged out]]></string> + <string name="synchronization_summary_unchoosen">You can choose from multiple providers to synchronize your subscriptions and episode play state with</string> + <string name="synchronization_summary_nextcloud">Gpoddersync is an open-source Nextcloud app that you can easily install on your own server. The app is independent of the AntennaPod project.</string> + <string name="synchronization_nextcloud_authenticate_browser">Grant access using the opened web browser and come back to AntennaPod.</string> + <string name="synchronization_choose_title">Choose synchronization provider</string> + <string name="synchronization_force_sync_summary">Re-synchronize all subscriptions and episode states</string> + <string name="synchronization_sync_summary">Synchronize subscription and episode state changes</string> + <string name="dialog_choose_sync_service_title">Choose synchronization provider</string> <string name="pref_playback_speed_sum">Customize the speeds available for variable speed playback</string> <string name="pref_feed_playback_speed_sum">The speed to use when starting audio playback for episodes in this podcast</string> <string name="pref_feed_skip">Auto Skip</string> @@ -500,9 +502,6 @@ <string name="pref_proxy_title">Proxy</string> <string name="pref_proxy_sum">Set a network proxy</string> <string name="pref_no_browser_found">No web browser found.</string> - <string name="pref_cast_title">Chromecast support</string> - <string name="pref_cast_message_play_flavor">Enable support for remote media playback on Cast devices (such as Chromecast, Audio Speakers or Android TV)</string> - <string name="pref_cast_message_free_flavor" tools:ignore="UnusedResources">Chromecast requires third party proprietary libraries that are disabled in this version of AntennaPod</string> <string name="pref_enqueue_downloaded_title">Enqueue Downloaded</string> <string name="pref_enqueue_downloaded_summary">Add downloaded episodes to the queue</string> <string name="media_player_builtin">Built-in Android player (deprecated) </string> @@ -565,6 +564,7 @@ <string name="sync_status_episodes_download">Downloading episode changes…</string> <string name="sync_status_upload_played">Uploading played status…</string> <string name="sync_status_subscriptions">Synchronizing subscriptions…</string> + <string name="sync_status_wait_for_downloads">Waiting for downloads to complete…</string> <string name="sync_status_success">Synchronization successful</string> <string name="sync_status_error">Synchronization failed</string> @@ -594,7 +594,6 @@ <string name="export_success_title">Export successful</string> <string name="export_success_sum">The exported file was written to:\n\n%1$s</string> <string name="opml_import_ask_read_permission">Access to external storage is required to read the OPML file</string> - <string name="import_select_file">Select file to import</string> <string name="successful_import_label">Import successful</string> <string name="import_ok">Please press OK to restart AntennaPod</string> <string name="import_no_downgrade">This database was exported with a newer version of AntennaPod. Your current installation does not yet know how to handle this file.</string> @@ -662,7 +661,6 @@ <string name="pref_pausePlaybackForFocusLoss_title">Pause for Interruptions</string> <string name="pref_resumeAfterCall_sum">Resume playback after a phone call completes</string> <string name="pref_resumeAfterCall_title">Resume after Call</string> - <string name="pref_restart_required">AntennaPod has to be restarted for this change to take effect.</string> <!-- Online feed view --> <string name="subscribe_label">Subscribe</string> @@ -671,6 +669,7 @@ <string name="stop_preview">Stop preview</string> <!-- Content descriptions for image buttons --> + <string name="toolbar_back_button_content_description">Back</string> <string name="rewind_label">Rewind</string> <string name="fast_forward_label">Fast forward</string> <string name="increase_speed">Increase speed</string> @@ -689,21 +688,26 @@ <!-- Feed settings/information screen --> <string name="authentication_label">Authentication</string> <string name="authentication_descr">Change your username and password for this podcast and its episodes.</string> - <string name="feed_folders_label">Folders</string> - <string name="feed_folders_summary">Change the folders in which this podcast is displayed.</string> + <string name="feed_tags_label">Tags</string> + <string name="feed_tags_summary">Change the tags of this podcast to help organize your subscriptions</string> <string name="feed_folders_include_root">Show in main list</string> + <string name="multi_feed_common_tags_info">{fa-info-circle} Only common tags from all selected subscriptions are shown. Other tags stay unaffected.</string> <string name="auto_download_settings_label">Auto Download Settings</string> <string name="episode_filters_label">Episode Filter</string> <string name="episode_filters_description">List of terms used to decide if an episode should be included or excluded when auto downloading</string> <string name="episode_filters_include">Include</string> <string name="episode_filters_exclude">Exclude</string> + <string name="episode_filters_duration">Minimal Duration (in minutes)</string> <string name="episode_filters_hint">Single words \n\"Multiple Words\"</string> <string name="keep_updated">Keep Updated</string> <string name="keep_updated_summary">Include this podcast when (auto-)refreshing all podcasts</string> <string name="auto_download_disabled_globally">Auto download is disabled in the main AntennaPod settings</string> - <string name="statistics_listened_for">Listened for:</string> + <string name="statistics_time_played">Time played:</string> + <string name="statistics_total_duration">Total duration (estimate):</string> + <string name="statistics_duration_played_episodes">Duration of played episodes:</string> <string name="statistics_episodes_on_device">Episodes on the device:</string> <string name="statistics_space_used">Space used:</string> + <string name="statistics_episodes_started_total">Episodes started/total:</string> <string name="statistics_view_all">View for all podcasts »</string> <!-- AntennaPodSP --> @@ -800,21 +804,6 @@ <!-- Subscriptions fragment --> <string name="subscription_num_columns">Number of columns</string> - <!-- Casting --> - <string name="cast_media_route_menu_title">Play on…</string> - <string name="cast_disconnect_label">Disconnect the cast session</string> - <string name="cast_not_castable">Media selected is not compatible with cast device</string> - <string name="cast_failed_to_play">Failed to start the playback of media</string> - <string name="cast_failed_to_stop">Failed to stop the playback of media</string> - <string name="cast_failed_to_pause">Failed to pause the playback of media</string> - <string name="cast_failed_setting_volume">Failed to set the volume</string> - <string name="cast_failed_no_connection">No connection to the cast device is present</string> - <string name="cast_failed_no_connection_trans">Connection to the cast device has been lost. Application is trying to re-establish the connection, if possible. Please wait for a few seconds and try again.</string> - <string name="cast_failed_status_request">Failed to sync up with the cast device</string> - <string name="cast_failed_seek">Failed to seek to the new position on the cast device</string> - <string name="cast_failed_receiver_player_error">Receiver player has encountered a severe error</string> - <string name="cast_failed_media_error_skipping">Error playing media. Skipping…</string> - <!-- Notification channels --> <string name="notification_group_errors">Errors</string> <string name="notification_group_news">News</string> @@ -842,4 +831,8 @@ <string name="on_demand_config_setting_changed">Setting updated successfully.</string> <string name="on_demand_config_stream_text">Looks like you stream a lot. Do you want episode lists to show stream buttons?</string> <string name="on_demand_config_download_text">Looks like you download a lot. Do you want episode lists to show download buttons?</string> + + <string name="shortcut_subscription_label">Subscription shortcut</string> + <string name="shortcut_select_subscription">Select subscription</string> + <string name="add_shortcut">Add Shortcut</string> </resources> diff --git a/core/src/play/java/de/danoeh/antennapod/core/CastCallbacks.java b/core/src/play/java/de/danoeh/antennapod/core/CastCallbacks.java deleted file mode 100644 index 27f985a4c..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/CastCallbacks.java +++ /dev/null @@ -1,12 +0,0 @@ -package de.danoeh.antennapod.core; - -import androidx.annotation.Nullable; -import androidx.mediarouter.app.MediaRouteDialogFactory; - -/** - * Callbacks for Chromecast support on the core module - */ -public interface CastCallbacks { - - @Nullable MediaRouteDialogFactory getMediaRouterDialogFactory(); -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java deleted file mode 100644 index 48de7c6e1..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java +++ /dev/null @@ -1,64 +0,0 @@ -package de.danoeh.antennapod.core; - -import android.content.Context; -import android.util.Log; -import de.danoeh.antennapod.core.cast.CastManager; -import de.danoeh.antennapod.core.preferences.PlaybackPreferences; -import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; -import de.danoeh.antennapod.core.preferences.UsageStatistics; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; -import de.danoeh.antennapod.core.storage.PodDBAdapter; -import de.danoeh.antennapod.core.util.NetworkUtils; -import de.danoeh.antennapod.core.util.gui.NotificationUtils; -import de.danoeh.antennapod.net.ssl.SslProviderInstaller; - -import java.io.File; - -/** - * Stores callbacks for core classes like Services, DB classes etc. and other configuration variables. - * Apps using the core module of AntennaPod should register implementations of all interfaces here. - */ -public class ClientConfig { - private static final String TAG = "ClientConfig"; - - private ClientConfig(){} - - /** - * Should be used when setting User-Agent header for HTTP-requests. - */ - public static String USER_AGENT; - - public static ApplicationCallbacks applicationCallbacks; - - public static DownloadServiceCallbacks downloadServiceCallbacks; - - public static CastCallbacks castCallbacks; - - private static boolean initialized = false; - - public static synchronized void initialize(Context context) { - if (initialized) { - return; - } - PodDBAdapter.init(context); - UserPreferences.init(context); - UsageStatistics.init(context); - PlaybackPreferences.init(context); - SslProviderInstaller.install(context); - NetworkUtils.init(context); - // Don't initialize Cast-related logic unless it is enabled, to avoid the unnecessary - // Google Play Service usage. - // Down side: when the user decides to enable casting, AntennaPod needs to be restarted - // for it to take effect. - if (UserPreferences.isCastEnabled()) { - CastManager.init(context); - } else { - Log.v(TAG, "Cast is disabled. All Cast-related initialization will be skipped."); - } - AntennapodHttpClient.setCacheDirectory(new File(context.getCacheDir(), "okhttp")); - SleepTimerPreferences.init(context); - NotificationUtils.createChannels(context); - initialized = true; - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastButtonVisibilityManager.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastButtonVisibilityManager.java deleted file mode 100644 index 8d0e40116..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/CastButtonVisibilityManager.java +++ /dev/null @@ -1,120 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; - -import de.danoeh.antennapod.core.R; - -public class CastButtonVisibilityManager { - private static final String TAG = "CastBtnVisibilityMgr"; - private final CastManager castManager; - private volatile boolean prefEnabled = false; - private volatile boolean viewRequested = false; - private volatile boolean resumed = false; - private volatile boolean connected = false; - private volatile int showAsAction = MenuItem.SHOW_AS_ACTION_IF_ROOM; - private Menu menu; - public SwitchableMediaRouteActionProvider mediaRouteActionProvider; - - public CastButtonVisibilityManager(CastManager castManager) { - this.castManager = castManager; - } - - public synchronized void setPrefEnabled(boolean newValue) { - if (prefEnabled != newValue && resumed && (viewRequested || connected)) { - if (newValue) { - castManager.incrementUiCounter(); - } else { - castManager.decrementUiCounter(); - } - } - prefEnabled = newValue; - if (mediaRouteActionProvider != null) { - mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); - } - } - - public synchronized void setResumed(boolean newValue) { - if (resumed == newValue) { - Log.e(TAG, "resumed should never change to the same value"); - return; - } - resumed = newValue; - if (prefEnabled && (viewRequested || connected)) { - if (resumed) { - castManager.incrementUiCounter(); - } else { - castManager.decrementUiCounter(); - } - } - } - - public synchronized void setViewRequested(boolean newValue) { - if (viewRequested != newValue && resumed && prefEnabled && !connected) { - if (newValue) { - castManager.incrementUiCounter(); - } else { - castManager.decrementUiCounter(); - } - } - viewRequested = newValue; - if (mediaRouteActionProvider != null) { - mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); - } - } - - public synchronized void setConnected(boolean newValue) { - if (connected != newValue && resumed && prefEnabled && !prefEnabled) { - if (newValue) { - castManager.incrementUiCounter(); - } else { - castManager.decrementUiCounter(); - } - } - connected = newValue; - if (mediaRouteActionProvider != null) { - mediaRouteActionProvider.setEnabled(prefEnabled && (viewRequested || connected)); - } - } - - public synchronized boolean shouldEnable() { - return prefEnabled && viewRequested; - } - - public void setMenu(Menu menu) { - setViewRequested(false); - showAsAction = MenuItem.SHOW_AS_ACTION_IF_ROOM; - this.menu = menu; - setShowAsAction(); - } - - public void requestCastButton(int showAsAction) { - setViewRequested(true); - this.showAsAction = showAsAction; - setShowAsAction(); - } - - public void onConnected() { - setConnected(true); - setShowAsAction(); - } - - public void onDisconnected() { - setConnected(false); - setShowAsAction(); - } - - private void setShowAsAction() { - if (menu == null) { - Log.d(TAG, "setShowAsAction() without a menu"); - return; - } - MenuItem item = menu.findItem(R.id.media_route_menu_item); - if (item == null) { - Log.e(TAG, "setShowAsAction(), but cast button not inflated"); - return; - } - item.setShowAsAction(connected ? MenuItem.SHOW_AS_ACTION_ALWAYS : showAsAction); - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java deleted file mode 100644 index 213dd1875..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/CastConsumer.java +++ /dev/null @@ -1,11 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumer; - -public interface CastConsumer extends VideoCastConsumer{ - - /** - * Called when the stream's volume is changed. - */ - void onStreamVolumeChanged(double value, boolean isMute); -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java deleted file mode 100644 index dd07b9cd8..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/CastManager.java +++ /dev/null @@ -1,1091 +0,0 @@ -/* - * Copyright (C) 2015 Google Inc. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * ------------------------------------------------------------------------ - * - * Changes made by Domingos Lopes <domingos86lopes@gmail.com> - * - * original can be found at http://www.github.com/googlecast/CastCompanionLibrary-android - */ - -package de.danoeh.antennapod.core.cast; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.core.view.ActionProvider; -import androidx.core.view.MenuItemCompat; -import androidx.mediarouter.media.MediaRouter; -import android.util.Log; -import android.view.MenuItem; - -import com.google.android.gms.cast.ApplicationMetadata; -import com.google.android.gms.cast.Cast; -import com.google.android.gms.cast.CastDevice; -import com.google.android.gms.cast.CastMediaControlIntent; -import com.google.android.gms.cast.CastStatusCodes; -import com.google.android.gms.cast.MediaInfo; -import com.google.android.gms.cast.MediaMetadata; -import com.google.android.gms.cast.MediaQueueItem; -import com.google.android.gms.cast.MediaStatus; -import com.google.android.gms.cast.RemoteMediaPlayer; -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GoogleApiAvailability; -import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager; -import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.OnFailedListener; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; - -import org.json.JSONObject; - -import java.io.IOException; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; - -import de.danoeh.antennapod.core.ClientConfig; -import de.danoeh.antennapod.core.R; - -import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_PLAY; -import static com.google.android.gms.cast.RemoteMediaPlayer.RESUME_STATE_UNCHANGED; - -/** - * A subclass of {@link BaseCastManager} that is suitable for casting video contents (it - * also provides a single custom data channel/namespace if an out-of-band communication is - * needed). - * <p> - * Clients need to initialize this class by calling - * {@link #init(android.content.Context)} in the Application's - * {@code onCreate()} method. To access the (singleton) instance of this class, clients - * need to call {@link #getInstance()}. - * <p>This - * class manages various states of the remote cast device. Client applications, however, can - * complement the default behavior of this class by hooking into various callbacks that it provides - * (see {@link CastConsumer}). - * Since the number of these callbacks is usually much larger than what a single application might - * be interested in, there is a no-op implementation of this interface (see - * {@link DefaultCastConsumer}) that applications can subclass to override only those methods that - * they are interested in. Since this library depends on the cast functionalities provided by the - * Google Play services, the library checks to ensure that the right version of that service is - * installed. It also provides a simple static method {@code checkGooglePlayServices()} that clients - * can call at an early stage of their applications to provide a dialog for users if they need to - * update/activate their Google Play Services library. - * - * @see CastConfiguration - */ -public class CastManager extends BaseCastManager implements OnFailedListener { - public static final String TAG = "CastManager"; - - public static final String CAST_APP_ID = CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID; - - private MediaStatus mediaStatus; - private static CastManager INSTANCE; - private RemoteMediaPlayer remoteMediaPlayer; - private int state = MediaStatus.PLAYER_STATE_IDLE; - private final Set<CastConsumer> castConsumers = new CopyOnWriteArraySet<>(); - - public static final int QUEUE_OPERATION_LOAD = 1; - public static final int QUEUE_OPERATION_APPEND = 9; - - private CastManager(Context context, CastConfiguration castConfiguration) { - super(context, castConfiguration); - Log.d(TAG, "CastManager is instantiated"); - } - - public static synchronized CastManager init(Context context) { - if (INSTANCE == null) { - CastConfiguration castConfiguration = new CastConfiguration.Builder(CAST_APP_ID) - .enableDebug() - .enableAutoReconnect() - .enableWifiReconnection() - .setLaunchOptions(true, Locale.getDefault()) - .setMediaRouteDialogFactory(ClientConfig.castCallbacks.getMediaRouterDialogFactory()) - .build(); - Log.d(TAG, "New instance of CastManager is created"); - if (ConnectionResult.SUCCESS != GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(context)) { - Log.e(TAG, "Couldn't find the appropriate version of Google Play Services"); - } - INSTANCE = new CastManager(context, castConfiguration); - } - return INSTANCE; - } - - /** - * Returns a (singleton) instance of this class. Clients should call this method in order to - * get a hold of this singleton instance, only after it is initialized. If it is not initialized - * yet, an {@link IllegalStateException} will be thrown. - * - */ - public static CastManager getInstance() { - if (INSTANCE == null) { - String msg = "No CastManager instance was found, did you forget to initialize it?"; - Log.e(TAG, msg); - throw new IllegalStateException(msg); - } - return INSTANCE; - } - - public static boolean isInitialized() { - return INSTANCE != null; - } - - /** - * Returns the active {@link RemoteMediaPlayer} instance. Since there are a number of media - * control APIs that this library do not provide a wrapper for, client applications can call - * those methods directly after obtaining an instance of the active {@link RemoteMediaPlayer}. - */ - public final RemoteMediaPlayer getRemoteMediaPlayer() { - return remoteMediaPlayer; - } - - /* - * A simple check to make sure remoteMediaPlayer is not null - */ - private void checkRemoteMediaPlayerAvailable() throws NoConnectionException { - if (remoteMediaPlayer == null) { - throw new NoConnectionException(); - } - } - - /** - * Indicates if the remote media is currently playing (or buffering). - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public boolean isRemoteMediaPlaying() throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - return state == MediaStatus.PLAYER_STATE_BUFFERING - || state == MediaStatus.PLAYER_STATE_PLAYING; - } - - /** - * Returns <code>true</code> if the remote connected device is playing a movie. - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public boolean isRemoteMediaPaused() throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - return state == MediaStatus.PLAYER_STATE_PAUSED; - } - - /** - * Returns <code>true</code> only if there is a media on the remote being played, paused or - * buffered. - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public boolean isRemoteMediaLoaded() throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - return isRemoteMediaPaused() || isRemoteMediaPlaying(); - } - - /** - * Gets the remote's system volume. It internally detects what type of volume is used. - * - * @throws NoConnectionException If no connectivity to the device exists - * @throws TransientNetworkDisconnectionException If framework is still trying to recover from - * a possibly transient loss of network - */ - public double getStreamVolume() throws TransientNetworkDisconnectionException, NoConnectionException { - checkConnectivity(); - checkRemoteMediaPlayerAvailable(); - return remoteMediaPlayer.getMediaStatus().getStreamVolume(); - } - - /** - * Sets the stream volume. - * - * @param volume Should be a value between 0 and 1, inclusive. - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - * @throws CastException If setting system volume fails - */ - public void setStreamVolume(double volume) throws CastException, - TransientNetworkDisconnectionException, NoConnectionException { - checkConnectivity(); - if (volume > 1.0) { - volume = 1.0; - } else if (volume < 0) { - volume = 0.0; - } - - RemoteMediaPlayer mediaPlayer = getRemoteMediaPlayer(); - if (mediaPlayer == null) { - throw new NoConnectionException(); - } - mediaPlayer.setStreamVolume(mApiClient, volume).setResultCallback( - (result) -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_setting_volume, - result.getStatus().getStatusCode()); - } else { - CastManager.this.onStreamVolumeChanged(); - } - }); - } - - /** - * Returns <code>true</code> if remote Stream is muted. - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public boolean isStreamMute() throws TransientNetworkDisconnectionException, NoConnectionException { - checkConnectivity(); - checkRemoteMediaPlayerAvailable(); - return remoteMediaPlayer.getMediaStatus().isMute(); - } - - /** - * Returns the duration of the media that is loaded, in milliseconds. - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public long getMediaDuration() throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - checkRemoteMediaPlayerAvailable(); - return remoteMediaPlayer.getStreamDuration(); - } - - /** - * Returns the current (approximate) position of the current media, in milliseconds. - * - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public long getCurrentMediaPosition() throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - checkRemoteMediaPlayerAvailable(); - return remoteMediaPlayer.getApproximateStreamPosition(); - } - - public int getApplicationStandbyState() throws IllegalStateException { - Log.d(TAG, "getApplicationStandbyState()"); - return Cast.CastApi.getStandbyState(mApiClient); - } - - private void onApplicationDisconnected(int errorCode) { - Log.d(TAG, "onApplicationDisconnected() reached with error code: " + errorCode); - mApplicationErrorCode = errorCode; - for (CastConsumer consumer : castConsumers) { - consumer.onApplicationDisconnected(errorCode); - } - if (mMediaRouter != null) { - Log.d(TAG, "onApplicationDisconnected(): Cached RouteInfo: " + getRouteInfo()); - Log.d(TAG, "onApplicationDisconnected(): Selected RouteInfo: " - + mMediaRouter.getSelectedRoute()); - if (getRouteInfo() == null || mMediaRouter.getSelectedRoute().equals(getRouteInfo())) { - Log.d(TAG, "onApplicationDisconnected(): Setting route to default"); - mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); - } - } - onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); - } - - private void onApplicationStatusChanged() { - if (!isConnected()) { - return; - } - try { - String appStatus = Cast.CastApi.getApplicationStatus(mApiClient); - Log.d(TAG, "onApplicationStatusChanged() reached: " + appStatus); - for (CastConsumer consumer : castConsumers) { - consumer.onApplicationStatusChanged(appStatus); - } - } catch (IllegalStateException e) { - Log.e(TAG, "onApplicationStatusChanged()", e); - } - } - - private void onDeviceVolumeChanged() { - Log.d(TAG, "onDeviceVolumeChanged() reached"); - double volume; - try { - volume = getDeviceVolume(); - boolean isMute = isDeviceMute(); - for (CastConsumer consumer : castConsumers) { - consumer.onVolumeChanged(volume, isMute); - } - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Failed to get volume", e); - } - - } - - private void onStreamVolumeChanged() { - Log.d(TAG, "onStreamVolumeChanged() reached"); - double volume; - try { - volume = getStreamVolume(); - boolean isMute = isStreamMute(); - for (CastConsumer consumer : castConsumers) { - consumer.onStreamVolumeChanged(volume, isMute); - } - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Failed to get volume", e); - } - } - - @Override - protected void onApplicationConnected(ApplicationMetadata appMetadata, - String applicationStatus, String sessionId, boolean wasLaunched) { - Log.d(TAG, "onApplicationConnected() reached with sessionId: " + sessionId - + ", and mReconnectionStatus=" + mReconnectionStatus); - mApplicationErrorCode = NO_APPLICATION_ERROR; - if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { - // we have tried to reconnect and successfully launched the app, so - // it is time to select the route and make the cast icon happy :-) - List<MediaRouter.RouteInfo> routes = mMediaRouter.getRoutes(); - if (routes != null) { - String routeId = mPreferenceAccessor.getStringFromPreference(PREFS_KEY_ROUTE_ID); - for (MediaRouter.RouteInfo routeInfo : routes) { - if (routeId.equals(routeInfo.getId())) { - // found the right route - Log.d(TAG, "Found the correct route during reconnection attempt"); - mReconnectionStatus = RECONNECTION_STATUS_FINALIZED; - mMediaRouter.selectRoute(routeInfo); - break; - } - } - } - } - try { - //attachDataChannel(); - attachMediaChannel(); - mSessionId = sessionId; - // saving device for future retrieval; we only save the last session info - mPreferenceAccessor.saveStringToPreference(PREFS_KEY_SESSION_ID, mSessionId); - remoteMediaPlayer.requestStatus(mApiClient).setResultCallback(result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_status_request, - result.getStatus().getStatusCode()); - } - }); - for (CastConsumer consumer : castConsumers) { - consumer.onApplicationConnected(appMetadata, mSessionId, wasLaunched); - } - } catch (TransientNetworkDisconnectionException e) { - Log.e(TAG, "Failed to attach media/data channel due to network issues", e); - onFailed(R.string.cast_failed_no_connection_trans, NO_STATUS_CODE); - } catch (NoConnectionException e) { - Log.e(TAG, "Failed to attach media/data channel due to network issues", e); - onFailed(R.string.cast_failed_no_connection, NO_STATUS_CODE); - } - } - - /* - * (non-Javadoc) - * @see com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager - * #onConnectivityRecovered() - */ - @Override - public void onConnectivityRecovered() { - reattachMediaChannel(); - //reattachDataChannel(); - super.onConnectivityRecovered(); - } - - /* - * (non-Javadoc) - * @see com.google.android.gms.cast.CastClient.Listener#onApplicationStopFailed (int) - */ - @Override - public void onApplicationStopFailed(int errorCode) { - for (CastConsumer consumer : castConsumers) { - consumer.onApplicationStopFailed(errorCode); - } - } - - @Override - public void onApplicationConnectionFailed(int errorCode) { - Log.d(TAG, "onApplicationConnectionFailed() reached with errorCode: " + errorCode); - mApplicationErrorCode = errorCode; - if (mReconnectionStatus == RECONNECTION_STATUS_IN_PROGRESS) { - if (errorCode == CastStatusCodes.APPLICATION_NOT_RUNNING) { - // while trying to re-establish session, we found out that the app is not running - // so we need to disconnect - mReconnectionStatus = RECONNECTION_STATUS_INACTIVE; - onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); - } - } else { - for (CastConsumer consumer : castConsumers) { - consumer.onApplicationConnectionFailed(errorCode); - } - onDeviceSelected(null /* CastDevice */, null /* RouteInfo */); - if (mMediaRouter != null) { - Log.d(TAG, "onApplicationConnectionFailed(): Setting route to default"); - mMediaRouter.selectRoute(mMediaRouter.getDefaultRoute()); - } - } - } - - /** - * Loads a media. For this to succeed, you need to have successfully launched the application. - * - * @param media The media to be loaded - * @param autoPlay If <code>true</code>, playback starts after load - * @param position Where to start the playback (only used if autoPlay is <code>true</code>. - * Units is milliseconds. - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void loadMedia(MediaInfo media, boolean autoPlay, int position) - throws TransientNetworkDisconnectionException, NoConnectionException { - loadMedia(media, autoPlay, position, null); - } - - /** - * Loads a media. For this to succeed, you need to have successfully launched the application. - * - * @param media The media to be loaded - * @param autoPlay If <code>true</code>, playback starts after load - * @param position Where to start the playback (only used if autoPlay is <code>true</code>). - * Units is milliseconds. - * @param customData Optional {@link JSONObject} data to be passed to the cast device - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void loadMedia(MediaInfo media, boolean autoPlay, int position, JSONObject customData) - throws TransientNetworkDisconnectionException, NoConnectionException { - loadMedia(media, null, autoPlay, position, customData); - } - - /** - * Loads a media. For this to succeed, you need to have successfully launched the application. - * - * @param media The media to be loaded - * @param activeTracks An array containing the list of track IDs to be set active for this - * media upon a successful load - * @param autoPlay If <code>true</code>, playback starts after load - * @param position Where to start the playback (only used if autoPlay is <code>true</code>). - * Units is milliseconds. - * @param customData Optional {@link JSONObject} data to be passed to the cast device - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void loadMedia(MediaInfo media, final long[] activeTracks, boolean autoPlay, - int position, JSONObject customData) - throws TransientNetworkDisconnectionException, NoConnectionException { - Log.d(TAG, "loadMedia"); - checkConnectivity(); - if (media == null) { - return; - } - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to load a video with no active media session"); - throw new NoConnectionException(); - } - - Log.d(TAG, "remoteMediaPlayer.load() with media=" + media.getMetadata().getString(MediaMetadata.KEY_TITLE) - + ", position=" + position + ", autoplay=" + autoPlay); - remoteMediaPlayer.load(mApiClient, media, autoPlay, position, activeTracks, customData) - .setResultCallback(result -> { - for (CastConsumer consumer : castConsumers) { - consumer.onMediaLoadResult(result.getStatus().getStatusCode()); - } - }); - } - - /** - * Loads and optionally starts playback of a new queue of media items. - * - * @param items Array of items to load, in the order that they should be played. Must not be - * {@code null} or empty. - * @param startIndex The array index of the item in the {@code items} array that should be - * played first (i.e., it will become the currentItem).If {@code repeatMode} - * is {@link MediaStatus#REPEAT_MODE_REPEAT_OFF} playback will end when the - * last item in the array is played. - * <p> - * This may be useful for continuation scenarios where the user was already - * using the sender application and in the middle decides to cast. This lets - * the sender application avoid mapping between the local and remote queue - * positions and/or avoid issuing an extra request to update the queue. - * <p> - * This value must be less than the length of {@code items}. - * @param repeatMode The repeat playback mode for the queue. One of - * {@link MediaStatus#REPEAT_MODE_REPEAT_OFF}, - * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL}, - * {@link MediaStatus#REPEAT_MODE_REPEAT_SINGLE} and - * {@link MediaStatus#REPEAT_MODE_REPEAT_ALL_AND_SHUFFLE}. - * @param customData Custom application-specific data to pass along with the request, may be - * {@code null}. - * @throws TransientNetworkDisconnectionException - * @throws NoConnectionException - */ - public void queueLoad(final MediaQueueItem[] items, final int startIndex, final int repeatMode, - final JSONObject customData) - throws TransientNetworkDisconnectionException, NoConnectionException { - Log.d(TAG, "queueLoad"); - checkConnectivity(); - if (items == null || items.length == 0) { - return; - } - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to queue one or more videos with no active media session"); - throw new NoConnectionException(); - } - Log.d(TAG, "remoteMediaPlayer.queueLoad() with " + items.length + "items, starting at " - + startIndex); - remoteMediaPlayer - .queueLoad(mApiClient, items, startIndex, repeatMode, customData) - .setResultCallback(result -> { - for (CastConsumer consumer : castConsumers) { - consumer.onMediaQueueOperationResult(QUEUE_OPERATION_LOAD, - result.getStatus().getStatusCode()); - } - }); - } - - /** - * Plays the loaded media. - * - * @param position Where to start the playback. Units is milliseconds. - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void play(int position) throws TransientNetworkDisconnectionException, - NoConnectionException { - checkConnectivity(); - Log.d(TAG, "attempting to play media at position " + position + " seconds"); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to play a video with no active media session"); - throw new NoConnectionException(); - } - seekAndPlay(position); - } - - /** - * Resumes the playback from where it was left (can be the beginning). - * - * @param customData Optional {@link JSONObject} data to be passed to the cast device - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void play(JSONObject customData) throws - TransientNetworkDisconnectionException, NoConnectionException { - Log.d(TAG, "play(customData)"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to play a video with no active media session"); - throw new NoConnectionException(); - } - remoteMediaPlayer.play(mApiClient, customData) - .setResultCallback(result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_to_play, - result.getStatus().getStatusCode()); - } - }); - } - - /** - * Resumes the playback from where it was left (can be the beginning). - * - * @throws CastException - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void play() throws CastException, TransientNetworkDisconnectionException, - NoConnectionException { - play(null); - } - - /** - * Stops the playback of media/stream - * - * @param customData Optional {@link JSONObject} - * @throws TransientNetworkDisconnectionException - * @throws NoConnectionException - */ - public void stop(JSONObject customData) throws - TransientNetworkDisconnectionException, NoConnectionException { - Log.d(TAG, "stop()"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to stop a stream with no active media session"); - throw new NoConnectionException(); - } - remoteMediaPlayer.stop(mApiClient, customData).setResultCallback( - result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_to_stop, - result.getStatus().getStatusCode()); - } - } - ); - } - - /** - * Stops the playback of media/stream - * - * @throws CastException - * @throws TransientNetworkDisconnectionException - * @throws NoConnectionException - */ - public void stop() throws CastException, - TransientNetworkDisconnectionException, NoConnectionException { - stop(null); - } - - /** - * Pauses the playback. - * - * @throws CastException - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void pause() throws CastException, TransientNetworkDisconnectionException, - NoConnectionException { - pause(null); - } - - /** - * Pauses the playback. - * - * @param customData Optional {@link JSONObject} data to be passed to the cast device - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void pause(JSONObject customData) throws - TransientNetworkDisconnectionException, NoConnectionException { - Log.d(TAG, "attempting to pause media"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to pause a video with no active media session"); - throw new NoConnectionException(); - } - remoteMediaPlayer.pause(mApiClient, customData) - .setResultCallback(result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_to_pause, - result.getStatus().getStatusCode()); - } - }); - } - - /** - * Seeks to the given point without changing the state of the player, i.e. after seek is - * completed, it resumes what it was doing before the start of seek. - * - * @param position in milliseconds - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void seek(int position) throws TransientNetworkDisconnectionException, - NoConnectionException { - Log.d(TAG, "attempting to seek media"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to seek a video with no active media session"); - throw new NoConnectionException(); - } - Log.d(TAG, "remoteMediaPlayer.seek() to position " + position); - remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_UNCHANGED).setResultCallback(result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode()); - } - }); - } - - /** - * Fast forwards the media by the given amount. If {@code lengthInMillis} is negative, it - * rewinds the media. - * - * @param lengthInMillis The amount to fast forward the media, given in milliseconds - * @throws TransientNetworkDisconnectionException - * @throws NoConnectionException - */ - public void forward(int lengthInMillis) throws TransientNetworkDisconnectionException, - NoConnectionException { - Log.d(TAG, "forward(): attempting to forward media by " + lengthInMillis); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to seek a video with no active media session"); - throw new NoConnectionException(); - } - long position = remoteMediaPlayer.getApproximateStreamPosition() + lengthInMillis; - seek((int) position); - } - - /** - * Seeks to the given point and starts playback regardless of the starting state. - * - * @param position in milliseconds - * @throws NoConnectionException - * @throws TransientNetworkDisconnectionException - */ - public void seekAndPlay(int position) throws TransientNetworkDisconnectionException, - NoConnectionException { - Log.d(TAG, "attempting to seek media"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - Log.e(TAG, "Trying to seekAndPlay a video with no active media session"); - throw new NoConnectionException(); - } - Log.d(TAG, "remoteMediaPlayer.seek() to position " + position + "and play"); - remoteMediaPlayer.seek(mApiClient, position, RESUME_STATE_PLAY) - .setResultCallback(result -> { - if (!result.getStatus().isSuccess()) { - onFailed(R.string.cast_failed_seek, result.getStatus().getStatusCode()); - } - }); - } - - private void attachMediaChannel() throws TransientNetworkDisconnectionException, - NoConnectionException { - Log.d(TAG, "attachMediaChannel()"); - checkConnectivity(); - if (remoteMediaPlayer == null) { - remoteMediaPlayer = new RemoteMediaPlayer(); - - remoteMediaPlayer.setOnStatusUpdatedListener( - () -> { - Log.d(TAG, "RemoteMediaPlayer::onStatusUpdated() is reached"); - CastManager.this.onRemoteMediaPlayerStatusUpdated(); - } - ); - - remoteMediaPlayer.setOnPreloadStatusUpdatedListener( - () -> { - Log.d(TAG, "RemoteMediaPlayer::onPreloadStatusUpdated() is reached"); - CastManager.this.onRemoteMediaPreloadStatusUpdated(); - }); - - - remoteMediaPlayer.setOnMetadataUpdatedListener( - () -> { - Log.d(TAG, "RemoteMediaPlayer::onMetadataUpdated() is reached"); - CastManager.this.onRemoteMediaPlayerMetadataUpdated(); - } - ); - - remoteMediaPlayer.setOnQueueStatusUpdatedListener( - () -> { - Log.d(TAG, "RemoteMediaPlayer::onQueueStatusUpdated() is reached"); - mediaStatus = remoteMediaPlayer.getMediaStatus(); - if (mediaStatus != null - && mediaStatus.getQueueItems() != null) { - List<MediaQueueItem> queueItems = mediaStatus - .getQueueItems(); - int itemId = mediaStatus.getCurrentItemId(); - MediaQueueItem item = mediaStatus - .getQueueItemById(itemId); - int repeatMode = mediaStatus.getQueueRepeatMode(); - onQueueUpdated(queueItems, item, repeatMode, false); - } else { - onQueueUpdated(null, null, - MediaStatus.REPEAT_MODE_REPEAT_OFF, false); - } - }); - - } - try { - Log.d(TAG, "Registering MediaChannel namespace"); - Cast.CastApi.setMessageReceivedCallbacks(mApiClient, remoteMediaPlayer.getNamespace(), - remoteMediaPlayer); - } catch (IOException | IllegalStateException e) { - Log.e(TAG, "attachMediaChannel()", e); - } - } - - private void reattachMediaChannel() { - if (remoteMediaPlayer != null && mApiClient != null) { - try { - Log.d(TAG, "Registering MediaChannel namespace"); - Cast.CastApi.setMessageReceivedCallbacks(mApiClient, - remoteMediaPlayer.getNamespace(), remoteMediaPlayer); - } catch (IOException | IllegalStateException e) { - Log.e(TAG, "reattachMediaChannel()", e); - } - } - } - - private void detachMediaChannel() { - Log.d(TAG, "trying to detach media channel"); - if (remoteMediaPlayer != null) { - try { - Cast.CastApi.removeMessageReceivedCallbacks(mApiClient, - remoteMediaPlayer.getNamespace()); - } catch (IOException | IllegalStateException e) { - Log.e(TAG, "detachMediaChannel()", e); - } - remoteMediaPlayer = null; - } - } - - /** - * Returns the latest retrieved value for the {@link MediaStatus}. This value is updated - * whenever the onStatusUpdated callback is called. - */ - public final MediaStatus getMediaStatus() { - return mediaStatus; - } - - /* - * This is called by onStatusUpdated() of the RemoteMediaPlayer - */ - private void onRemoteMediaPlayerStatusUpdated() { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated() reached"); - if (mApiClient == null || remoteMediaPlayer == null) { - Log.d(TAG, "mApiClient or remoteMediaPlayer is null, so will not proceed"); - return; - } - mediaStatus = remoteMediaPlayer.getMediaStatus(); - if (mediaStatus == null) { - Log.d(TAG, "MediaStatus is null, so will not proceed"); - return; - } else { - List<MediaQueueItem> queueItems = mediaStatus.getQueueItems(); - if (queueItems != null) { - int itemId = mediaStatus.getCurrentItemId(); - MediaQueueItem item = mediaStatus.getQueueItemById(itemId); - int repeatMode = mediaStatus.getQueueRepeatMode(); - onQueueUpdated(queueItems, item, repeatMode, false); - } else { - onQueueUpdated(null, null, MediaStatus.REPEAT_MODE_REPEAT_OFF, false); - } - state = mediaStatus.getPlayerState(); - int idleReason = mediaStatus.getIdleReason(); - - if (state == MediaStatus.PLAYER_STATE_PLAYING) { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = playing"); - } else if (state == MediaStatus.PLAYER_STATE_PAUSED) { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = paused"); - } else if (state == MediaStatus.PLAYER_STATE_IDLE) { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = IDLE with reason: " - + idleReason); - if (idleReason == MediaStatus.IDLE_REASON_ERROR) { - // something bad happened on the cast device - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): IDLE reason = ERROR"); - onFailed(R.string.cast_failed_receiver_player_error, NO_STATUS_CODE); - } - } else if (state == MediaStatus.PLAYER_STATE_BUFFERING) { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = buffering"); - } else { - Log.d(TAG, "onRemoteMediaPlayerStatusUpdated(): Player status = unknown"); - } - } - for (CastConsumer consumer : castConsumers) { - consumer.onRemoteMediaPlayerStatusUpdated(); - } - if (mediaStatus != null) { - double volume = mediaStatus.getStreamVolume(); - boolean isMute = mediaStatus.isMute(); - for (CastConsumer consumer : castConsumers) { - consumer.onStreamVolumeChanged(volume, isMute); - } - } - } - - private void onRemoteMediaPreloadStatusUpdated() { - MediaQueueItem item = null; - mediaStatus = remoteMediaPlayer.getMediaStatus(); - if (mediaStatus != null) { - item = mediaStatus.getQueueItemById(mediaStatus.getPreloadedItemId()); - } - Log.d(TAG, "onRemoteMediaPreloadStatusUpdated() " + item); - for (CastConsumer consumer : castConsumers) { - consumer.onRemoteMediaPreloadStatusUpdated(item); - } - } - - /* - * This is called by onQueueStatusUpdated() of RemoteMediaPlayer - */ - private void onQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item, - int repeatMode, boolean shuffle) { - Log.d(TAG, "onQueueUpdated() reached"); - Log.d(TAG, String.format(Locale.US, "Queue Items size: %d, Item: %s, Repeat Mode: %d, Shuffle: %s", - queueItems == null ? 0 : queueItems.size(), item, repeatMode, shuffle)); - for (CastConsumer consumer : castConsumers) { - consumer.onMediaQueueUpdated(queueItems, item, repeatMode, shuffle); - } - } - - /* - * This is called by onMetadataUpdated() of RemoteMediaPlayer - */ - public void onRemoteMediaPlayerMetadataUpdated() { - Log.d(TAG, "onRemoteMediaPlayerMetadataUpdated() reached"); - for (CastConsumer consumer : castConsumers) { - consumer.onRemoteMediaPlayerMetadataUpdated(); - } - } - - /** - * Registers a {@link CastConsumer} interface with this class. - * Registered listeners will be notified of changes to a variety of - * lifecycle and media status changes through the callbacks that the interface provides. - * - * @see DefaultCastConsumer - */ - public synchronized void addCastConsumer(CastConsumer listener) { - if (listener != null) { - addBaseCastConsumer(listener); - castConsumers.add(listener); - Log.d(TAG, "Successfully added the new CastConsumer listener " + listener); - } - } - - /** - * Unregisters a {@link CastConsumer}. - */ - public synchronized void removeCastConsumer(CastConsumer listener) { - if (listener != null) { - removeBaseCastConsumer(listener); - castConsumers.remove(listener); - } - } - - @Override - protected void onDeviceUnselected() { - detachMediaChannel(); - //removeDataChannel(); - state = MediaStatus.PLAYER_STATE_IDLE; - mediaStatus = null; - } - - @Override - protected Cast.CastOptions.Builder getCastOptionBuilder(CastDevice device) { - Cast.CastOptions.Builder builder = new Cast.CastOptions.Builder(mSelectedCastDevice, new CastListener()); - if (isFeatureEnabled(CastConfiguration.FEATURE_DEBUGGING)) { - builder.setVerboseLoggingEnabled(true); - } - return builder; - } - - @Override - public void onConnectionFailed(ConnectionResult result) { - super.onConnectionFailed(result); - state = MediaStatus.PLAYER_STATE_IDLE; - mediaStatus = null; - } - - @Override - public void onDisconnected(boolean stopAppOnExit, boolean clearPersistedConnectionData, - boolean setDefaultRoute) { - super.onDisconnected(stopAppOnExit, clearPersistedConnectionData, setDefaultRoute); - state = MediaStatus.PLAYER_STATE_IDLE; - mediaStatus = null; - } - - class CastListener extends Cast.Listener { - - /* - * (non-Javadoc) - * @see com.google.android.gms.cast.Cast.Listener#onApplicationDisconnected (int) - */ - @Override - public void onApplicationDisconnected(int statusCode) { - CastManager.this.onApplicationDisconnected(statusCode); - } - - /* - * (non-Javadoc) - * @see com.google.android.gms.cast.Cast.Listener#onApplicationStatusChanged () - */ - @Override - public void onApplicationStatusChanged() { - CastManager.this.onApplicationStatusChanged(); - } - - @Override - public void onVolumeChanged() { - CastManager.this.onDeviceVolumeChanged(); - } - } - - @Override - public void onFailed(int resourceId, int statusCode) { - Log.d(TAG, "onFailed: " + mContext.getString(resourceId) + ", code: " + statusCode); - super.onFailed(resourceId, statusCode); - } - - /** - * Checks whether the selected Cast Device has the specified audio or video capabilities. - * - * @param capability capability from: - * <ul> - * <li>{@link CastDevice#CAPABILITY_AUDIO_IN}</li> - * <li>{@link CastDevice#CAPABILITY_AUDIO_OUT}</li> - * <li>{@link CastDevice#CAPABILITY_VIDEO_IN}</li> - * <li>{@link CastDevice#CAPABILITY_VIDEO_OUT}</li> - * </ul> - * @param defaultVal value to return whenever there's no device selected. - * @return {@code true} if the selected device has the specified capability, - * {@code false} otherwise. - */ - public boolean hasCapability(final int capability, final boolean defaultVal) { - if (mSelectedCastDevice != null) { - return mSelectedCastDevice.hasCapability(capability); - } else { - return defaultVal; - } - } - - /** - * Adds and wires up the Switchable Media Router cast button. It returns a reference to the - * {@link SwitchableMediaRouteActionProvider} associated with the button if the caller needs - * such reference. It is assumed that the enclosing - * {@link android.app.Activity} inherits (directly or indirectly) from - * {@link androidx.appcompat.app.AppCompatActivity}. - * - * @param menuItem MenuItem of the Media Router cast button. - */ - public final SwitchableMediaRouteActionProvider addMediaRouterButton(@NonNull MenuItem menuItem) { - ActionProvider actionProvider = MenuItemCompat.getActionProvider(menuItem); - if (!(actionProvider instanceof SwitchableMediaRouteActionProvider)) { - Log.wtf(TAG, "MenuItem provided to addMediaRouterButton() is not compatible with " + - "SwitchableMediaRouteActionProvider." + - ((actionProvider == null) ? " Its action provider is null!" : ""), - new ClassCastException()); - return null; - } - SwitchableMediaRouteActionProvider mediaRouteActionProvider = - (SwitchableMediaRouteActionProvider) actionProvider; - mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector); - if (mCastConfiguration.getMediaRouteDialogFactory() != null) { - mediaRouteActionProvider.setDialogFactory(mCastConfiguration.getMediaRouteDialogFactory()); - } - return mediaRouteActionProvider; - } - - /* (non-Javadoc) - * These methods startReconnectionService and stopReconnectionService simply override the ones - * from BaseCastManager with empty implementations because we handle the service ourselves, but - * need to allow BaseCastManager to save current network information. - */ - @Override - protected void startReconnectionService(long mediaDurationLeft) { - // Do nothing - } - - @Override - protected void stopReconnectionService() { - // Do nothing - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java deleted file mode 100644 index e1f52aa9f..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java +++ /dev/null @@ -1,303 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import android.content.ContentResolver; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; - -import com.google.android.gms.cast.CastDevice; -import com.google.android.gms.cast.MediaInfo; -import com.google.android.gms.cast.MediaMetadata; -import com.google.android.gms.common.images.WebImage; - -import java.util.Calendar; -import java.util.List; - -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.model.feed.FeedItem; -import de.danoeh.antennapod.model.feed.FeedMedia; -import de.danoeh.antennapod.model.playback.Playable; -import de.danoeh.antennapod.model.playback.RemoteMedia; -import de.danoeh.antennapod.core.storage.DBReader; - -/** - * Helper functions for Cast support. - */ -public class CastUtils { - private CastUtils(){} - - private static final String TAG = "CastUtils"; - - public static final String KEY_MEDIA_ID = "de.danoeh.antennapod.core.cast.MediaId"; - - public static final String KEY_EPISODE_IDENTIFIER = "de.danoeh.antennapod.core.cast.EpisodeId"; - public static final String KEY_EPISODE_LINK = "de.danoeh.antennapod.core.cast.EpisodeLink"; - public static final String KEY_FEED_URL = "de.danoeh.antennapod.core.cast.FeedUrl"; - public static final String KEY_FEED_WEBSITE = "de.danoeh.antennapod.core.cast.FeedWebsite"; - public static final String KEY_EPISODE_NOTES = "de.danoeh.antennapod.core.cast.EpisodeNotes"; - - /** - * The field <code>AntennaPod.FormatVersion</code> specifies which version of MediaMetaData - * fields we're using. Future implementations should try to be backwards compatible with earlier - * versions, and earlier versions should be forward compatible until the version indicated by - * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code>. If an update makes the format unreadable for - * an earlier version, then its version number should be greater than the - * <code>MAX_VERSION_FORWARD_COMPATIBILITY</code> value set on the earlier one, so that it - * doesn't try to parse the object. - */ - public static final String KEY_FORMAT_VERSION = "de.danoeh.antennapod.core.cast.FormatVersion"; - public static final int FORMAT_VERSION_VALUE = 1; - public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999; - - public static boolean isCastable(Playable media) { - if (media == null) { - return false; - } - if (media instanceof FeedMedia || media instanceof RemoteMedia) { - String url = media.getStreamUrl(); - if (url == null || url.isEmpty()) { - return false; - } - if (url.startsWith(ContentResolver.SCHEME_CONTENT)) { - return false; // Local feed - } - switch (media.getMediaType()) { - case UNKNOWN: - return false; - case AUDIO: - return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT, true); - case VIDEO: - return CastManager.getInstance().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT, true); - } - } - return false; - } - - /** - * Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device. - * Before using this method, one should make sure {@link #isCastable(Playable)} returns - * {@code true}. This method should not run on the main thread. - * - * @param media The {@link FeedMedia} object to be converted. - * @return {@link MediaInfo} object in a format proper for casting. - */ - public static MediaInfo convertFromFeedMedia(FeedMedia media){ - if (media == null) { - return null; - } - MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); - if (media.getItem() == null) { - media.setItem(DBReader.getFeedItem(media.getItemId())); - } - FeedItem feedItem = media.getItem(); - if (feedItem != null) { - metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle()); - String subtitle = media.getFeedTitle(); - if (subtitle != null) { - metadata.putString(MediaMetadata.KEY_SUBTITLE, subtitle); - } - - if (!TextUtils.isEmpty(feedItem.getImageLocation())) { - metadata.addImage(new WebImage(Uri.parse(feedItem.getImageLocation()))); - } - Calendar calendar = Calendar.getInstance(); - calendar.setTime(media.getItem().getPubDate()); - metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar); - Feed feed = feedItem.getFeed(); - if (feed != null) { - if (!TextUtils.isEmpty(feed.getAuthor())) { - metadata.putString(MediaMetadata.KEY_ARTIST, feed.getAuthor()); - } - if (!TextUtils.isEmpty(feed.getDownload_url())) { - metadata.putString(KEY_FEED_URL, feed.getDownload_url()); - } - if (!TextUtils.isEmpty(feed.getLink())) { - metadata.putString(KEY_FEED_WEBSITE, feed.getLink()); - } - } - if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) { - metadata.putString(KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier()); - } else { - metadata.putString(KEY_EPISODE_IDENTIFIER, media.getStreamUrl()); - } - if (!TextUtils.isEmpty(feedItem.getLink())) { - metadata.putString(KEY_EPISODE_LINK, feedItem.getLink()); - } - try { - DBReader.loadDescriptionOfFeedItem(feedItem); - metadata.putString(KEY_EPISODE_NOTES, feedItem.getDescription()); - } catch (Exception e) { - Log.e(TAG, "Unable to load FeedMedia notes", e); - } - } - // This field only identifies the id on the device that has the original version. - // Idea is to perhaps, on a first approach, check if the version on the local DB with the - // same id matches the remote object, and if not then search for episode and feed identifiers. - // This at least should make media recognition for a single device much quicker. - metadata.putInt(KEY_MEDIA_ID, ((Long) media.getIdentifier()).intValue()); - // A way to identify different casting media formats in case we change it in the future and - // senders with different versions share a casting device. - metadata.putInt(KEY_FORMAT_VERSION, FORMAT_VERSION_VALUE); - - MediaInfo.Builder builder = new MediaInfo.Builder(media.getStreamUrl()) - .setContentType(media.getMime_type()) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setMetadata(metadata); - if (media.getDuration() > 0) { - builder.setStreamDuration(media.getDuration()); - } - return builder.build(); - } - - //TODO make unit tests for all the conversion methods - /** - * Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}. - * - * Unless <code>searchFeedMedia</code> is set to <code>false</code>, this method should not run - * on the GUI thread. - * - * @param media The {@link MediaInfo} object to be converted. - * @param searchFeedMedia If set to <code>true</code>, the database will be queried to find a - * {@link FeedMedia} instance that matches {@param media}. - * @return {@link Playable} object in a format proper for casting. - */ - public static Playable getPlayable(MediaInfo media, boolean searchFeedMedia) { - Log.d(TAG, "getPlayable called with searchFeedMedia=" + searchFeedMedia); - if (media == null) { - Log.d(TAG, "MediaInfo object provided is null, not converting to any Playable instance"); - return null; - } - MediaMetadata metadata = media.getMetadata(); - int version = metadata.getInt(KEY_FORMAT_VERSION); - if (version <= 0 || version > MAX_VERSION_FORWARD_COMPATIBILITY) { - Log.w(TAG, "MediaInfo object obtained from the cast device is not compatible with this" + - "version of AntennaPod CastUtils, curVer=" + FORMAT_VERSION_VALUE + - ", object version=" + version); - return null; - } - Playable result = null; - if (searchFeedMedia) { - long mediaId = metadata.getInt(KEY_MEDIA_ID); - if (mediaId > 0) { - FeedMedia fMedia = DBReader.getFeedMedia(mediaId); - if (fMedia != null) { - if (matches(media, fMedia)) { - result = fMedia; - Log.d(TAG, "FeedMedia object obtained matches the MediaInfo provided. id=" + mediaId); - } else { - Log.d(TAG, "FeedMedia object obtained does NOT match the MediaInfo provided. id=" + mediaId); - } - } else { - Log.d(TAG, "Unable to find in database a FeedMedia with id=" + mediaId); - } - } - if (result == null) { - FeedItem feedItem = DBReader.getFeedItemByGuidOrEpisodeUrl(null, - metadata.getString(KEY_EPISODE_IDENTIFIER)); - if (feedItem != null) { - result = feedItem.getMedia(); - Log.d(TAG, "Found episode that matches the MediaInfo provided. Using its media, if existing."); - } - } - } - if (result == null) { - List<WebImage> imageList = metadata.getImages(); - String imageUrl = null; - if (!imageList.isEmpty()) { - imageUrl = imageList.get(0).getUrl().toString(); - } - String notes = metadata.getString(KEY_EPISODE_NOTES); - result = new RemoteMedia(media.getContentId(), - metadata.getString(KEY_EPISODE_IDENTIFIER), - metadata.getString(KEY_FEED_URL), - metadata.getString(MediaMetadata.KEY_SUBTITLE), - metadata.getString(MediaMetadata.KEY_TITLE), - metadata.getString(KEY_EPISODE_LINK), - metadata.getString(MediaMetadata.KEY_ARTIST), - imageUrl, - metadata.getString(KEY_FEED_WEBSITE), - media.getContentType(), - metadata.getDate(MediaMetadata.KEY_RELEASE_DATE).getTime(), - notes); - Log.d(TAG, "Converted MediaInfo into RemoteMedia"); - } - if (result.getDuration() == 0 && media.getStreamDuration() > 0) { - result.setDuration((int) media.getStreamDuration()); - } - return result; - } - - /** - * Compares a {@link MediaInfo} instance with a {@link FeedMedia} one and evaluates whether they - * represent the same podcast episode. - * - * @param info the {@link MediaInfo} object to be compared. - * @param media the {@link FeedMedia} object to be compared. - * @return <true>true</true> if there's a match, <code>false</code> otherwise. - * - * @see RemoteMedia#equals(Object) - */ - public static boolean matches(MediaInfo info, FeedMedia media) { - if (info == null || media == null) { - return false; - } - if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) { - return false; - } - MediaMetadata metadata = info.getMetadata(); - FeedItem fi = media.getItem(); - if (fi == null || metadata == null || - !TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), fi.getItemIdentifier())) { - return false; - } - Feed feed = fi.getFeed(); - return feed != null && TextUtils.equals(metadata.getString(KEY_FEED_URL), feed.getDownload_url()); - } - - /** - * Compares a {@link MediaInfo} instance with a {@link RemoteMedia} one and evaluates whether they - * represent the same podcast episode. - * - * @param info the {@link MediaInfo} object to be compared. - * @param media the {@link RemoteMedia} object to be compared. - * @return <true>true</true> if there's a match, <code>false</code> otherwise. - * - * @see RemoteMedia#equals(Object) - */ - public static boolean matches(MediaInfo info, RemoteMedia media) { - if (info == null || media == null) { - return false; - } - if (!TextUtils.equals(info.getContentId(), media.getStreamUrl())) { - return false; - } - MediaMetadata metadata = info.getMetadata(); - return metadata != null && - TextUtils.equals(metadata.getString(KEY_EPISODE_IDENTIFIER), media.getEpisodeIdentifier()) && - TextUtils.equals(metadata.getString(KEY_FEED_URL), media.getFeedUrl()); - } - - /** - * Compares a {@link MediaInfo} instance with a {@link Playable} and evaluates whether they - * represent the same podcast episode. Useful every time we get a MediaInfo from the Cast Device - * and want to avoid unnecessary conversions. - * - * @param info the {@link MediaInfo} object to be compared. - * @param media the {@link Playable} object to be compared. - * @return <true>true</true> if there's a match, <code>false</code> otherwise. - * - * @see RemoteMedia#equals(Object) - */ - public static boolean matches(MediaInfo info, Playable media) { - if (info == null || media == null) { - return false; - } - if (media instanceof RemoteMedia) { - return matches(info, (RemoteMedia) media); - } - return media instanceof FeedMedia && matches(info, (FeedMedia) media); - } - - - //TODO Queue handling perhaps -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java b/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java deleted file mode 100644 index fe4183d54..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/DefaultCastConsumer.java +++ /dev/null @@ -1,10 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl; - -public class DefaultCastConsumer extends VideoCastConsumerImpl implements CastConsumer { - @Override - public void onStreamVolumeChanged(double value, boolean isMute) { - // no-op - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/MediaInfoCreator.java b/core/src/play/java/de/danoeh/antennapod/core/cast/MediaInfoCreator.java deleted file mode 100644 index 00011ef05..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/MediaInfoCreator.java +++ /dev/null @@ -1,57 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import android.net.Uri; -import android.text.TextUtils; -import com.google.android.gms.cast.MediaInfo; -import com.google.android.gms.cast.MediaMetadata; -import com.google.android.gms.common.images.WebImage; -import de.danoeh.antennapod.model.playback.RemoteMedia; -import java.util.Calendar; - -public class MediaInfoCreator { - public static MediaInfo from(RemoteMedia media) { - MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); - - metadata.putString(MediaMetadata.KEY_TITLE, media.getEpisodeTitle()); - metadata.putString(MediaMetadata.KEY_SUBTITLE, media.getFeedTitle()); - if (!TextUtils.isEmpty(media.getImageLocation())) { - metadata.addImage(new WebImage(Uri.parse(media.getImageLocation()))); - } - Calendar calendar = Calendar.getInstance(); - calendar.setTime(media.getPubDate()); - metadata.putDate(MediaMetadata.KEY_RELEASE_DATE, calendar); - if (!TextUtils.isEmpty(media.getFeedAuthor())) { - metadata.putString(MediaMetadata.KEY_ARTIST, media.getFeedAuthor()); - } - if (!TextUtils.isEmpty(media.getFeedUrl())) { - metadata.putString(CastUtils.KEY_FEED_URL, media.getFeedUrl()); - } - if (!TextUtils.isEmpty(media.getFeedLink())) { - metadata.putString(CastUtils.KEY_FEED_WEBSITE, media.getFeedLink()); - } - if (!TextUtils.isEmpty(media.getEpisodeIdentifier())) { - metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getEpisodeIdentifier()); - } else { - metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getDownloadUrl()); - } - if (!TextUtils.isEmpty(media.getEpisodeLink())) { - metadata.putString(CastUtils.KEY_EPISODE_LINK, media.getEpisodeLink()); - } - String notes = media.getNotes(); - if (notes != null) { - metadata.putString(CastUtils.KEY_EPISODE_NOTES, notes); - } - // Default id value - metadata.putInt(CastUtils.KEY_MEDIA_ID, 0); - metadata.putInt(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE); - - MediaInfo.Builder builder = new MediaInfo.Builder(media.getDownloadUrl()) - .setContentType(media.getMimeType()) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) - .setMetadata(metadata); - if (media.getDuration() > 0) { - builder.setStreamDuration(media.getDuration()); - } - return builder.build(); - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java b/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java deleted file mode 100644 index 5a6a0aa2b..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/cast/SwitchableMediaRouteActionProvider.java +++ /dev/null @@ -1,106 +0,0 @@ -package de.danoeh.antennapod.core.cast; - -import android.app.Activity; -import android.content.Context; -import android.content.ContextWrapper; -import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; -import androidx.mediarouter.app.MediaRouteActionProvider; -import androidx.mediarouter.app.MediaRouteChooserDialogFragment; -import androidx.mediarouter.app.MediaRouteControllerDialogFragment; -import androidx.mediarouter.media.MediaRouter; -import android.util.Log; - -/** - * <p>Action Provider that extends {@link MediaRouteActionProvider} and allows the client to - * disable completely the button by calling {@link #setEnabled(boolean)}.</p> - * - * <p>It is disabled by default, so if a client wants to initially have it enabled it must call - * <code>setEnabled(true)</code>.</p> - */ -public class SwitchableMediaRouteActionProvider extends MediaRouteActionProvider { - public static final String TAG = "SwitchblMediaRtActProv"; - - private static final String CHOOSER_FRAGMENT_TAG = - "android.support.v7.mediarouter:MediaRouteChooserDialogFragment"; - private static final String CONTROLLER_FRAGMENT_TAG = - "android.support.v7.mediarouter:MediaRouteControllerDialogFragment"; - private boolean enabled; - - public SwitchableMediaRouteActionProvider(Context context) { - super(context); - enabled = false; - } - - /** - * <p>Sets whether the Media Router button should be allowed to become visible or not.</p> - * - * <p>It's invisible by default.</p> - */ - public void setEnabled(boolean newVal) { - enabled = newVal; - refreshVisibility(); - } - - @Override - public boolean isVisible() { - return enabled && super.isVisible(); - } - - @Override - public boolean onPerformDefaultAction() { - if (!super.onPerformDefaultAction()) { - // there is no button, but we should still show the dialog if it's the case. - if (!isVisible()) { - return false; - } - FragmentManager fm = getFragmentManager(); - if (fm == null) { - return false; - } - MediaRouter.RouteInfo route = MediaRouter.getInstance(getContext()).getSelectedRoute(); - if (route.isDefault() || !route.matchesSelector(getRouteSelector())) { - if (fm.findFragmentByTag(CHOOSER_FRAGMENT_TAG) != null) { - Log.w(TAG, "showDialog(): Route chooser dialog already showing!"); - return false; - } - MediaRouteChooserDialogFragment f = - getDialogFactory().onCreateChooserDialogFragment(); - f.setRouteSelector(getRouteSelector()); - f.show(fm, CHOOSER_FRAGMENT_TAG); - } else { - if (fm.findFragmentByTag(CONTROLLER_FRAGMENT_TAG) != null) { - Log.w(TAG, "showDialog(): Route controller dialog already showing!"); - return false; - } - MediaRouteControllerDialogFragment f = - getDialogFactory().onCreateControllerDialogFragment(); - f.show(fm, CONTROLLER_FRAGMENT_TAG); - } - return true; - - } else { - return true; - } - } - - private FragmentManager getFragmentManager() { - Activity activity = getActivity(); - if (activity instanceof FragmentActivity) { - return ((FragmentActivity)activity).getSupportFragmentManager(); - } - return null; - } - - private Activity getActivity() { - // Gross way of unwrapping the Activity so we can get the FragmentManager - Context context = getContext(); - while (context instanceof ContextWrapper) { - if (context instanceof Activity) { - return (Activity)context; - } - context = ((ContextWrapper)context).getBaseContext(); - } - return null; - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java deleted file mode 100644 index 38e84017f..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceFlavorHelper.java +++ /dev/null @@ -1,314 +0,0 @@ -package de.danoeh.antennapod.core.service.playback; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.net.NetworkInfo; -import android.net.wifi.WifiManager; -import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import androidx.mediarouter.media.MediaRouter; -import android.support.wearable.media.MediaControlConstants; -import android.util.Log; -import android.widget.Toast; - -import com.google.android.gms.cast.ApplicationMetadata; -import com.google.android.libraries.cast.companionlibrary.cast.BaseCastManager; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import de.danoeh.antennapod.core.cast.CastConsumer; -import de.danoeh.antennapod.core.cast.CastManager; -import de.danoeh.antennapod.core.cast.DefaultCastConsumer; -import de.danoeh.antennapod.core.event.MessageEvent; -import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.util.NetworkUtils; -import org.greenrobot.eventbus.EventBus; - -/** - * Class intended to work along PlaybackService and provide support for different flavors. - */ -public class PlaybackServiceFlavorHelper { - public static final String TAG = "PlaybackSrvFlavorHelper"; - - /** - * Time in seconds during which the CastManager will try to reconnect to the Cast Device after - * the Wifi Connection is regained. - */ - private static final int RECONNECTION_ATTEMPT_PERIOD_S = 15; - /** - * Stores the state of the cast playback just before it disconnects. - */ - private volatile PlaybackServiceMediaPlayer.PSMPInfo infoBeforeCastDisconnection; - - private boolean wifiConnectivity = true; - private BroadcastReceiver wifiBroadcastReceiver; - - private CastManager castManager; - private MediaRouter mediaRouter; - private PlaybackService.FlavorHelperCallback callback; - private CastConsumer castConsumer; - - PlaybackServiceFlavorHelper(Context context, PlaybackService.FlavorHelperCallback callback) { - this.callback = callback; - if (!CastManager.isInitialized()) { - return; - } - mediaRouter = MediaRouter.getInstance(context.getApplicationContext()); - setCastConsumer(context); - } - - void initializeMediaPlayer(Context context) { - if (!CastManager.isInitialized()) { - callback.setMediaPlayer(new LocalPSMP(context, callback.getMediaPlayerCallback())); - return; - } - castManager = CastManager.getInstance(); - castManager.addCastConsumer(castConsumer); - boolean isCasting = castManager.isConnected(); - callback.setIsCasting(isCasting); - if (isCasting) { - if (UserPreferences.isCastEnabled()) { - onCastAppConnected(context, false); - } else { - castManager.disconnect(); - } - } else { - callback.setMediaPlayer(new LocalPSMP(context, callback.getMediaPlayerCallback())); - } - } - - void removeCastConsumer() { - if (!CastManager.isInitialized()) { - return; - } - castManager.removeCastConsumer(castConsumer); - } - - boolean castDisconnect(boolean castDisconnect) { - if (!CastManager.isInitialized()) { - return false; - } - if (castDisconnect) { - castManager.disconnect(); - } - return castDisconnect; - } - - boolean onMediaPlayerInfo(Context context, int code, @StringRes int resourceId) { - if (!CastManager.isInitialized()) { - return false; - } - switch (code) { - case RemotePSMP.CAST_ERROR: - EventBus.getDefault().post(new MessageEvent(context.getString(resourceId))); - return true; - case RemotePSMP.CAST_ERROR_PRIORITY_HIGH: - Toast.makeText(context, resourceId, Toast.LENGTH_SHORT).show(); - return true; - default: - return false; - } - } - - private void setCastConsumer(Context context) { - castConsumer = new DefaultCastConsumer() { - @Override - public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { - onCastAppConnected(context, wasLaunched); - } - - @Override - public void onDisconnectionReason(int reason) { - Log.d(TAG, "onDisconnectionReason() with code " + reason); - // This is our final chance to update the underlying stream position - // In onDisconnected(), the underlying CastPlayback#mVideoCastConsumer - // is disconnected and hence we update our local value of stream position - // to the latest position. - PlaybackServiceMediaPlayer mediaPlayer = callback.getMediaPlayer(); - if (mediaPlayer != null) { - callback.saveCurrentPosition(true, null, PlaybackServiceMediaPlayer.INVALID_TIME); - infoBeforeCastDisconnection = mediaPlayer.getPSMPInfo(); - if (reason != BaseCastManager.DISCONNECT_REASON_EXPLICIT && - infoBeforeCastDisconnection.playerStatus == PlayerStatus.PLAYING) { - // If it's NOT based on user action, we shouldn't automatically resume local playback - infoBeforeCastDisconnection.playerStatus = PlayerStatus.PAUSED; - } - } - } - - @Override - public void onDisconnected() { - Log.d(TAG, "onDisconnected()"); - callback.setIsCasting(false); - PlaybackServiceMediaPlayer.PSMPInfo info = infoBeforeCastDisconnection; - infoBeforeCastDisconnection = null; - PlaybackServiceMediaPlayer mediaPlayer = callback.getMediaPlayer(); - if (info == null && mediaPlayer != null) { - info = mediaPlayer.getPSMPInfo(); - } - if (info == null) { - info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.INDETERMINATE, - PlayerStatus.STOPPED, null); - } - switchMediaPlayer(new LocalPSMP(context, callback.getMediaPlayerCallback()), - info, true); - if (info.playable != null) { - callback.sendNotificationBroadcast(PlaybackService.NOTIFICATION_TYPE_RELOAD, - info.playable.getMediaType() == MediaType.AUDIO ? - PlaybackService.EXTRA_CODE_AUDIO : PlaybackService.EXTRA_CODE_VIDEO); - } else { - Log.d(TAG, "Cast session disconnected, but no current media"); - callback.sendNotificationBroadcast(PlaybackService.NOTIFICATION_TYPE_PLAYBACK_END, 0); - } - // hardware volume buttons control the local device volume - mediaRouter.setMediaSessionCompat(null); - unregisterWifiBroadcastReceiver(); - callback.setupNotification(false, info); - } - }; - } - - private void onCastAppConnected(Context context, boolean wasLaunched) { - Log.d(TAG, "A cast device application was " + (wasLaunched ? "launched" : "joined")); - callback.setIsCasting(true); - PlaybackServiceMediaPlayer.PSMPInfo info = null; - PlaybackServiceMediaPlayer mediaPlayer = callback.getMediaPlayer(); - if (mediaPlayer != null) { - info = mediaPlayer.getPSMPInfo(); - if (info.playerStatus == PlayerStatus.PLAYING) { - // could be pause, but this way we make sure the new player will get the correct position, - // since pause runs asynchronously and we could be directing the new player to play even before - // the old player gives us back the position. - callback.saveCurrentPosition(true, null, PlaybackServiceMediaPlayer.INVALID_TIME); - } - } - if (info == null) { - info = new PlaybackServiceMediaPlayer.PSMPInfo(PlayerStatus.INDETERMINATE, PlayerStatus.STOPPED, null); - } - callback.sendNotificationBroadcast(PlaybackService.NOTIFICATION_TYPE_RELOAD, - PlaybackService.EXTRA_CODE_CAST); - RemotePSMP remotePSMP = new RemotePSMP(context, callback.getMediaPlayerCallback()); - switchMediaPlayer(remotePSMP, info, wasLaunched); - remotePSMP.init(); - // hardware volume buttons control the remote device volume - mediaRouter.setMediaSessionCompat(callback.getMediaSession()); - registerWifiBroadcastReceiver(); - callback.setupNotification(true, info); - } - - private void switchMediaPlayer(@NonNull PlaybackServiceMediaPlayer newPlayer, - @NonNull PlaybackServiceMediaPlayer.PSMPInfo info, - boolean wasLaunched) { - PlaybackServiceMediaPlayer mediaPlayer = callback.getMediaPlayer(); - if (mediaPlayer != null) { - try { - mediaPlayer.stopPlayback(false).get(2, TimeUnit.SECONDS); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - Log.e(TAG, "There was a problem stopping playback while switching media players", e); - } - mediaPlayer.shutdownQuietly(); - } - mediaPlayer = newPlayer; - callback.setMediaPlayer(mediaPlayer); - Log.d(TAG, "switched to " + mediaPlayer.getClass().getSimpleName()); - if (!wasLaunched) { - PlaybackServiceMediaPlayer.PSMPInfo candidate = mediaPlayer.getPSMPInfo(); - if (candidate.playable != null && - candidate.playerStatus.isAtLeast(PlayerStatus.PREPARING)) { - // do not automatically send new media to cast device - info.playable = null; - } - } - if (info.playable != null) { - mediaPlayer.playMediaObject(info.playable, - !info.playable.localFileAvailable(), - info.playerStatus == PlayerStatus.PLAYING, - info.playerStatus.isAtLeast(PlayerStatus.PREPARING)); - } - } - - void registerWifiBroadcastReceiver() { - if (!CastManager.isInitialized()) { - return; - } - if (wifiBroadcastReceiver != null) { - return; - } - wifiBroadcastReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - if (intent.getAction().equals(WifiManager.NETWORK_STATE_CHANGED_ACTION)) { - NetworkInfo info = intent.getParcelableExtra(WifiManager.EXTRA_NETWORK_INFO); - boolean isConnected = info.isConnected(); - //apparently this method gets called twice when a change happens, but one run is enough. - if (isConnected && !wifiConnectivity) { - wifiConnectivity = true; - castManager.startCastDiscovery(); - castManager.reconnectSessionIfPossible(RECONNECTION_ATTEMPT_PERIOD_S, NetworkUtils.getWifiSsid()); - } else { - wifiConnectivity = isConnected; - } - } - } - }; - callback.registerReceiver(wifiBroadcastReceiver, - new IntentFilter(WifiManager.NETWORK_STATE_CHANGED_ACTION)); - } - - void unregisterWifiBroadcastReceiver() { - if (!CastManager.isInitialized()) { - return; - } - if (wifiBroadcastReceiver != null) { - callback.unregisterReceiver(wifiBroadcastReceiver); - wifiBroadcastReceiver = null; - } - } - - boolean onSharedPreference(String key) { - if (!CastManager.isInitialized()) { - return false; - } - if (UserPreferences.PREF_CAST_ENABLED.equals(key)) { - if (!UserPreferences.isCastEnabled()) { - if (castManager.isConnecting() || castManager.isConnected()) { - Log.d(TAG, "Disconnecting cast device due to a change in user preferences"); - castManager.disconnect(); - } - } - return true; - } - return false; - } - - void sessionStateAddActionForWear(PlaybackStateCompat.Builder sessionState, String actionName, CharSequence name, int icon) { - if (!CastManager.isInitialized()) { - return; - } - PlaybackStateCompat.CustomAction.Builder actionBuilder = - new PlaybackStateCompat.CustomAction.Builder(actionName, name, icon); - Bundle actionExtras = new Bundle(); - actionExtras.putBoolean(MediaControlConstants.EXTRA_CUSTOM_ACTION_SHOW_ON_WEAR, true); - actionBuilder.setExtras(actionExtras); - - sessionState.addCustomAction(actionBuilder.build()); - } - - void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) { - if (!CastManager.isInitialized()) { - return; - } - Bundle sessionExtras = new Bundle(); - sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_PREVIOUS, true); - sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_NEXT, true); - mediaSession.setExtras(sessionExtras); - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java deleted file mode 100644 index 38b469e8e..000000000 --- a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java +++ /dev/null @@ -1,680 +0,0 @@ -package de.danoeh.antennapod.core.service.playback; - -import android.content.Context; -import android.media.MediaPlayer; -import androidx.annotation.NonNull; -import android.util.Log; -import android.util.Pair; -import android.view.SurfaceHolder; - -import com.google.android.gms.cast.Cast; -import com.google.android.gms.cast.CastStatusCodes; -import com.google.android.gms.cast.MediaInfo; -import com.google.android.gms.cast.MediaStatus; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException; -import com.google.android.libraries.cast.companionlibrary.cast.exceptions.TransientNetworkDisconnectionException; - -import de.danoeh.antennapod.core.cast.MediaInfoCreator; - -import java.util.Collections; -import java.util.List; -import java.util.concurrent.Future; -import java.util.concurrent.FutureTask; -import java.util.concurrent.atomic.AtomicBoolean; - -import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.cast.CastConsumer; -import de.danoeh.antennapod.core.cast.CastManager; -import de.danoeh.antennapod.core.cast.CastUtils; -import de.danoeh.antennapod.core.cast.DefaultCastConsumer; -import de.danoeh.antennapod.core.storage.DBReader; -import de.danoeh.antennapod.model.playback.RemoteMedia; -import de.danoeh.antennapod.model.feed.FeedMedia; -import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; -import de.danoeh.antennapod.model.playback.Playable; - -/** - * Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices. - */ -public class RemotePSMP extends PlaybackServiceMediaPlayer { - - public static final String TAG = "RemotePSMP"; - - public static final int CAST_ERROR = 3001; - - public static final int CAST_ERROR_PRIORITY_HIGH = 3005; - - private final CastManager castMgr; - - private volatile Playable media; - private volatile MediaType mediaType; - private volatile MediaInfo remoteMedia; - private volatile int remoteState; - - private final AtomicBoolean isBuffering; - - private final AtomicBoolean startWhenPrepared; - - public RemotePSMP(@NonNull Context context, @NonNull PSMPCallback callback) { - super(context, callback); - - castMgr = CastManager.getInstance(); - media = null; - mediaType = null; - startWhenPrepared = new AtomicBoolean(false); - isBuffering = new AtomicBoolean(false); - remoteState = MediaStatus.PLAYER_STATE_UNKNOWN; - } - - public void init() { - try { - if (castMgr.isConnected() && castMgr.isRemoteMediaLoaded()) { - onRemoteMediaPlayerStatusUpdated(); - } - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to do initial check for loaded media", e); - } - - castMgr.addCastConsumer(castConsumer); - } - - private CastConsumer castConsumer = new DefaultCastConsumer() { - @Override - public void onRemoteMediaPlayerMetadataUpdated() { - RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(); - } - - @Override - public void onRemoteMediaPlayerStatusUpdated() { - RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(); - } - - @Override - public void onMediaLoadResult(int statusCode) { - if (playerStatus == PlayerStatus.PREPARING) { - if (statusCode == CastStatusCodes.SUCCESS) { - setPlayerStatus(PlayerStatus.PREPARED, media); - if (media.getDuration() == 0) { - Log.d(TAG, "Setting duration of media"); - try { - media.setDuration((int) castMgr.getMediaDuration()); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to get remote media's duration"); - } - } - } else if (statusCode != CastStatusCodes.REPLACED){ - Log.d(TAG, "Remote media failed to load"); - setPlayerStatus(PlayerStatus.INITIALIZED, media); - } - } else { - Log.d(TAG, "onMediaLoadResult called, but Player Status wasn't in preparing state, so we ignore the result"); - } - } - - @Override - public void onApplicationStatusChanged(String appStatus) { - if (playerStatus != PlayerStatus.PLAYING) { - Log.d(TAG, "onApplicationStatusChanged, but no media was playing"); - return; - } - boolean playbackEnded = false; - try { - int standbyState = castMgr.getApplicationStandbyState(); - Log.d(TAG, "standbyState: " + standbyState); - playbackEnded = standbyState == Cast.STANDBY_STATE_YES; - } catch (IllegalStateException e) { - Log.d(TAG, "unable to get standbyState on onApplicationStatusChanged()"); - } - if (playbackEnded) { - // This is an unconventional thing to occur... - Log.w(TAG, "Somehow, Chromecast went from playing directly to standby mode"); - endPlayback(false, false, true, true); - } - } - - @Override - public void onFailed(int resourceId, int statusCode) { - callback.onMediaPlayerInfo(CAST_ERROR, resourceId); - } - }; - - private void setBuffering(boolean buffering) { - if (buffering && isBuffering.compareAndSet(false, true)) { - callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); - } else if (!buffering && isBuffering.compareAndSet(true, false)) { - callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); - } - } - - private Playable localVersion(MediaInfo info){ - if (info == null) { - return null; - } - if (CastUtils.matches(info, media)) { - return media; - } - return CastUtils.getPlayable(info, true); - } - - private MediaInfo remoteVersion(Playable playable) { - if (playable == null) { - return null; - } - if (CastUtils.matches(remoteMedia, playable)) { - return remoteMedia; - } - if (playable instanceof FeedMedia) { - return CastUtils.convertFromFeedMedia((FeedMedia) playable); - } - if (playable instanceof RemoteMedia) { - return MediaInfoCreator.from((RemoteMedia) playable); - } - return null; - } - - private void onRemoteMediaPlayerStatusUpdated() { - MediaStatus status = castMgr.getMediaStatus(); - if (status == null) { - Log.d(TAG, "Received null MediaStatus"); - return; - } else { - Log.d(TAG, "Received remote status/media update. New state=" + status.getPlayerState()); - } - int state = status.getPlayerState(); - int oldState = remoteState; - remoteMedia = status.getMediaInfo(); - boolean mediaChanged = !CastUtils.matches(remoteMedia, media); - boolean stateChanged = state != oldState; - if (!mediaChanged && !stateChanged) { - Log.d(TAG, "Both media and state haven't changed, so nothing to do"); - return; - } - Playable currentMedia = mediaChanged ? localVersion(remoteMedia) : media; - Playable oldMedia = media; - int position = (int) status.getStreamPosition(); - // check for incompatible states - if ((state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_PAUSED) - && currentMedia == null) { - Log.w(TAG, "RemoteMediaPlayer returned playing or pausing state, but with no media"); - state = MediaStatus.PLAYER_STATE_UNKNOWN; - stateChanged = oldState != MediaStatus.PLAYER_STATE_UNKNOWN; - } - - if (stateChanged) { - remoteState = state; - } - - if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && - state != MediaStatus.PLAYER_STATE_IDLE) { - callback.onPlaybackPause(null, INVALID_TIME); - // We don't want setPlayerStatus to handle the onPlaybackPause callback - setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); - } - - setBuffering(state == MediaStatus.PLAYER_STATE_BUFFERING); - - switch (state) { - case MediaStatus.PLAYER_STATE_PLAYING: - if (!stateChanged) { - //These steps are necessary because they won't be performed by setPlayerStatus() - if (position >= 0) { - currentMedia.setPosition(position); - } - currentMedia.onPlaybackStart(); - } - setPlayerStatus(PlayerStatus.PLAYING, currentMedia, position); - break; - case MediaStatus.PLAYER_STATE_PAUSED: - setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position); - break; - case MediaStatus.PLAYER_STATE_BUFFERING: - setPlayerStatus((mediaChanged || playerStatus == PlayerStatus.PREPARING) ? - PlayerStatus.PREPARING : PlayerStatus.SEEKING, - currentMedia, - currentMedia != null ? currentMedia.getPosition() : INVALID_TIME); - break; - case MediaStatus.PLAYER_STATE_IDLE: - int reason = status.getIdleReason(); - switch (reason) { - case MediaStatus.IDLE_REASON_CANCELED: - // Essentially means stopped at the request of a user - callback.onPlaybackEnded(null, true); - setPlayerStatus(PlayerStatus.STOPPED, currentMedia); - if (oldMedia != null) { - if (position >= 0) { - oldMedia.setPosition(position); - } - callback.onPostPlayback(oldMedia, false, false, false); - } - // onPlaybackEnded pretty much takes care of updating the UI - return; - case MediaStatus.IDLE_REASON_INTERRUPTED: - // Means that a request to load a different media was sent - // Not sure if currentMedia already reflects the to be loaded one - if (mediaChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING) { - callback.onPlaybackPause(null, INVALID_TIME); - setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); - } - setPlayerStatus(PlayerStatus.PREPARING, currentMedia); - break; - case MediaStatus.IDLE_REASON_NONE: - // This probably only happens when we connected but no command has been sent yet. - setPlayerStatus(PlayerStatus.INITIALIZED, currentMedia); - break; - case MediaStatus.IDLE_REASON_FINISHED: - // This is our onCompletionListener... - if (mediaChanged && currentMedia != null) { - media = currentMedia; - } - endPlayback(true, false, true, true); - return; - case MediaStatus.IDLE_REASON_ERROR: - Log.w(TAG, "Got an error status from the Chromecast. Skipping, if possible, to the next episode..."); - callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, - R.string.cast_failed_media_error_skipping); - endPlayback(false, false, true, true); - return; - } - break; - case MediaStatus.PLAYER_STATE_UNKNOWN: - if (playerStatus != PlayerStatus.INDETERMINATE || media != currentMedia) { - setPlayerStatus(PlayerStatus.INDETERMINATE, currentMedia); - } - break; - default: - Log.wtf(TAG, "Remote media state undetermined!"); - } - if (mediaChanged) { - callback.onMediaChanged(true); - if (oldMedia != null) { - callback.onPostPlayback(oldMedia, false, false, currentMedia != null); - } - } - } - - @Override - public void playMediaObject(@NonNull final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { - Log.d(TAG, "playMediaObject() called"); - playMediaObject(playable, false, stream, startWhenPrepared, prepareImmediately); - } - - /** - * Internal implementation of playMediaObject. This method has an additional parameter that allows the caller to force a media player reset even if - * the given playable parameter is the same object as the currently playing media. - * - * @see #playMediaObject(Playable, boolean, boolean, boolean) - */ - private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { - if (!CastUtils.isCastable(playable)) { - Log.d(TAG, "media provided is not compatible with cast device"); - callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_not_castable); - Playable nextPlayable = playable; - do { - nextPlayable = callback.getNextInQueue(nextPlayable); - } while (nextPlayable != null && !CastUtils.isCastable(nextPlayable)); - if (nextPlayable != null) { - playMediaObject(nextPlayable, forceReset, stream, startWhenPrepared, prepareImmediately); - } - return; - } - - if (media != null) { - if (!forceReset && media.getIdentifier().equals(playable.getIdentifier()) - && playerStatus == PlayerStatus.PLAYING) { - // episode is already playing -> ignore method call - Log.d(TAG, "Method call to playMediaObject was ignored: media file already playing."); - return; - } else { - // set temporarily to pause in order to update list with current position - boolean isPlaying = playerStatus == PlayerStatus.PLAYING; - int position = media.getPosition(); - try { - isPlaying = castMgr.isRemoteMediaPlaying(); - position = (int) castMgr.getCurrentMediaPosition(); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to determine whether media was playing, falling back to stored player status", e); - } - if (isPlaying) { - callback.onPlaybackPause(media, position); - } - if (!media.getIdentifier().equals(playable.getIdentifier())) { - final Playable oldMedia = media; - callback.onPostPlayback(oldMedia, false, false, true); - } - - setPlayerStatus(PlayerStatus.INDETERMINATE, null); - } - } - - this.media = playable; - remoteMedia = remoteVersion(playable); - this.mediaType = media.getMediaType(); - this.startWhenPrepared.set(startWhenPrepared); - setPlayerStatus(PlayerStatus.INITIALIZING, media); - if (media instanceof FeedMedia && ((FeedMedia) media).getItem() == null) { - ((FeedMedia) media).setItem(DBReader.getFeedItem(((FeedMedia) media).getItemId())); - } - callback.onMediaChanged(true); - setPlayerStatus(PlayerStatus.INITIALIZED, media); - if (prepareImmediately) { - prepare(); - } - } - - @Override - public void resume() { - try { - if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { - int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( - media.getPosition(), - media.getLastPlayedTime()); - castMgr.play(newPosition); - } else { - castMgr.play(); - } - } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to resume remote playback", e); - } - } - - @Override - public void pause(boolean abandonFocus, boolean reinit) { - try { - if (castMgr.isRemoteMediaPlaying()) { - castMgr.pause(); - } - } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to pause", e); - } - } - - @Override - public void prepare() { - if (playerStatus == PlayerStatus.INITIALIZED) { - Log.d(TAG, "Preparing media player"); - setPlayerStatus(PlayerStatus.PREPARING, media); - try { - int position = media.getPosition(); - if (position > 0) { - position = RewindAfterPauseUtils.calculatePositionWithRewind( - position, - media.getLastPlayedTime()); - } - castMgr.loadMedia(remoteMedia, startWhenPrepared.get(), position); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Error loading media", e); - setPlayerStatus(PlayerStatus.INITIALIZED, media); - } - } - } - - @Override - public void reinit() { - Log.d(TAG, "reinit() called"); - if (media != null) { - playMediaObject(media, true, false, startWhenPrepared.get(), false); - } else { - Log.d(TAG, "Call to reinit was ignored: media was null"); - } - } - - @Override - public void seekTo(int t) { - //TODO check other seek implementations and see if there's no issue with sending too many seek commands to the remote media player - try { - if (castMgr.isRemoteMediaLoaded()) { - setPlayerStatus(PlayerStatus.SEEKING, media); - castMgr.seek(t); - } else if (media != null && playerStatus == PlayerStatus.INITIALIZED){ - media.setPosition(t); - startWhenPrepared.set(false); - prepare(); - } - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to seek", e); - } - } - - @Override - public void seekDelta(int d) { - int position = getPosition(); - if (position != INVALID_TIME) { - seekTo(position + d); - } else { - Log.e(TAG, "getPosition() returned INVALID_TIME in seekDelta"); - } - } - - @Override - public int getDuration() { - int retVal = INVALID_TIME; - boolean prepared; - try { - prepared = castMgr.isRemoteMediaLoaded(); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to check if remote media is loaded", e); - prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); - } - if (prepared) { - try { - retVal = (int) castMgr.getMediaDuration(); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to determine remote media's duration", e); - } - } - if(retVal == INVALID_TIME && media != null && media.getDuration() > 0) { - retVal = media.getDuration(); - } - Log.d(TAG, "getDuration() -> " + retVal); - return retVal; - } - - @Override - public int getPosition() { - int retVal = INVALID_TIME; - boolean prepared; - try { - prepared = castMgr.isRemoteMediaLoaded(); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to check if remote media is loaded", e); - prepared = playerStatus.isAtLeast(PlayerStatus.PREPARED); - } - if (prepared) { - try { - retVal = (int) castMgr.getCurrentMediaPosition(); - } catch (TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to determine remote media's position", e); - } - } - if(retVal <= 0 && media != null && media.getPosition() >= 0) { - retVal = media.getPosition(); - } - Log.d(TAG, "getPosition() -> " + retVal); - return retVal; - } - - @Override - public boolean isStartWhenPrepared() { - return startWhenPrepared.get(); - } - - @Override - public void setStartWhenPrepared(boolean startWhenPrepared) { - this.startWhenPrepared.set(startWhenPrepared); - } - - @Override - public void setPlaybackParams(float speed, boolean skipSilence) { - //Can be safely ignored as neither set speed not skipSilence is supported - } - - @Override - public float getPlaybackSpeed() { - return 1; - } - - @Override - public void setVolume(float volumeLeft, float volumeRight) { - Log.d(TAG, "Setting the Stream volume on Remote Media Player"); - double volume = (volumeLeft+volumeRight)/2; - if (volume > 1.0) { - volume = 1.0; - } - if (volume < 0.0) { - volume = 0.0; - } - try { - castMgr.setStreamVolume(volume); - } catch (TransientNetworkDisconnectionException | NoConnectionException | CastException e) { - Log.e(TAG, "Unable to set the volume", e); - } - } - - @Override - public boolean canDownmix() { - return false; - } - - @Override - public void setDownmix(boolean enable) { - throw new UnsupportedOperationException("Setting downmix unsupported in Remote Media Player"); - } - - @Override - public MediaType getCurrentMediaType() { - return mediaType; - } - - @Override - public boolean isStreaming() { - return true; - } - - @Override - public void shutdown() { - castMgr.removeCastConsumer(castConsumer); - } - - @Override - public void shutdownQuietly() { - shutdown(); - } - - @Override - public void setVideoSurface(SurfaceHolder surface) { - throw new UnsupportedOperationException("Setting Video Surface unsupported in Remote Media Player"); - } - - @Override - public void resetVideoSurface() { - Log.e(TAG, "Resetting Video Surface unsupported in Remote Media Player"); - } - - @Override - public Pair<Integer, Integer> getVideoSize() { - return null; - } - - @Override - public Playable getPlayable() { - return media; - } - - @Override - protected void setPlayable(Playable playable) { - if (playable != media) { - media = playable; - remoteMedia = remoteVersion(playable); - } - } - - @Override - public List<String> getAudioTracks() { - return Collections.emptyList(); - } - - public void setAudioTrack(int track) { - - } - - public int getSelectedAudioTrack() { - return -1; - } - - @Override - protected Future<?> endPlayback(boolean hasEnded, boolean wasSkipped, boolean shouldContinue, - boolean toStoppedState) { - Log.d(TAG, "endPlayback() called"); - boolean isPlaying = playerStatus == PlayerStatus.PLAYING; - if (playerStatus != PlayerStatus.INDETERMINATE) { - setPlayerStatus(PlayerStatus.INDETERMINATE, media); - } - if (media != null && wasSkipped) { - // current position only really matters when we skip - int position = getPosition(); - if (position >= 0) { - media.setPosition(position); - } - } - final Playable currentMedia = media; - Playable nextMedia = null; - if (shouldContinue) { - nextMedia = callback.getNextInQueue(currentMedia); - - boolean playNextEpisode = isPlaying && nextMedia != null; - if (playNextEpisode) { - Log.d(TAG, "Playback of next episode will start immediately."); - } else if (nextMedia == null){ - Log.d(TAG, "No more episodes available to play"); - } else { - Log.d(TAG, "Loading next episode, but not playing automatically."); - } - - if (nextMedia != null) { - callback.onPlaybackEnded(nextMedia.getMediaType(), !playNextEpisode); - // setting media to null signals to playMediaObject() that we're taking care of post-playback processing - media = null; - playMediaObject(nextMedia, false, true /*TODO for now we always stream*/, playNextEpisode, playNextEpisode); - } - } - if (shouldContinue || toStoppedState) { - boolean shouldPostProcess = true; - if (nextMedia == null) { - try { - castMgr.stop(); - shouldPostProcess = false; - } catch (CastException | TransientNetworkDisconnectionException | NoConnectionException e) { - Log.e(TAG, "Unable to stop playback", e); - callback.onPlaybackEnded(null, true); - stop(); - } - } - if (shouldPostProcess) { - // Otherwise we rely on the chromecast callback to tell us the playback has stopped. - callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, nextMedia != null); - } - } else if (isPlaying) { - callback.onPlaybackPause(currentMedia, - currentMedia != null ? currentMedia.getPosition() : INVALID_TIME); - } - - FutureTask<?> future = new FutureTask<>(() -> {}, null); - future.run(); - return future; - } - - private void stop() { - if (playerStatus == PlayerStatus.INDETERMINATE) { - setPlayerStatus(PlayerStatus.STOPPED, null); - } else { - Log.d(TAG, "Ignored call to stop: Current player state is: " + playerStatus); - } - } - - @Override - protected boolean shouldLockWifi() { - return false; - } -} diff --git a/core/src/play/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java new file mode 100644 index 000000000..2167d9f2c --- /dev/null +++ b/core/src/play/java/de/danoeh/antennapod/core/service/playback/WearMediaSession.java @@ -0,0 +1,28 @@ +package de.danoeh.antennapod.core.service.playback; + +import android.os.Bundle; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.support.wearable.media.MediaControlConstants; + +public class WearMediaSession { + public static final String TAG = "WearMediaSession"; + + static void sessionStateAddActionForWear(PlaybackStateCompat.Builder sessionState, String actionName, + CharSequence name, int icon) { + PlaybackStateCompat.CustomAction.Builder actionBuilder = + new PlaybackStateCompat.CustomAction.Builder(actionName, name, icon); + Bundle actionExtras = new Bundle(); + actionExtras.putBoolean(MediaControlConstants.EXTRA_CUSTOM_ACTION_SHOW_ON_WEAR, true); + actionBuilder.setExtras(actionExtras); + + sessionState.addCustomAction(actionBuilder.build()); + } + + static void mediaSessionSetExtraForWear(MediaSessionCompat mediaSession) { + Bundle sessionExtras = new Bundle(); + sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_PREVIOUS, true); + sessionExtras.putBoolean(MediaControlConstants.EXTRA_RESERVE_SLOT_SKIP_TO_NEXT, true); + mediaSession.setExtras(sessionExtras); + } +} diff --git a/core/src/play/res/values/strings.xml b/core/src/play/res/values/strings.xml deleted file mode 100644 index 7307849d2..000000000 --- a/core/src/play/res/values/strings.xml +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<resources> - <string name="pref_cast_message" translatable="false">@string/pref_cast_message_play_flavor</string> -</resources> diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java index 4ad578727..3840f6387 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java @@ -1,7 +1,10 @@ package de.danoeh.antennapod.core.feed; +import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.model.feed.FeedFilter; import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; + import org.junit.Test; import static org.junit.Assert.assertEquals; @@ -125,4 +128,32 @@ public class FeedFilterTest { assertFalse(filter.shouldAutoDownload(doNotDownload2)); } + @Test + public void testMinimalDurationFilter() { + FeedItem download = new FeedItem(); + download.setTitle("Hello friend!"); + FeedMedia downloadMedia = FeedMediaMother.anyFeedMedia(); + downloadMedia.setDuration(Converter.durationStringShortToMs("05:00", false)); + download.setMedia(downloadMedia); + // because duration of the media in unknown + FeedItem download2 = new FeedItem(); + download2.setTitle("Hello friend!"); + FeedMedia unknownDurationMedia = FeedMediaMother.anyFeedMedia(); + download2.setMedia(unknownDurationMedia); + // because it is not long enough + FeedItem doNotDownload = new FeedItem(); + doNotDownload.setTitle("Hello friend!"); + FeedMedia doNotDownloadMedia = FeedMediaMother.anyFeedMedia(); + doNotDownloadMedia.setDuration(Converter.durationStringShortToMs("02:00", false)); + doNotDownload.setMedia(doNotDownloadMedia); + + int minimalDurationFilter = 3 * 60; + FeedFilter filter = new FeedFilter("", "", minimalDurationFilter); + + assertTrue(filter.hasMinimalDurationFilter()); + assertTrue(filter.shouldAutoDownload(download)); + assertFalse(filter.shouldAutoDownload(doNotDownload)); + assertTrue(filter.shouldAutoDownload(download2)); + } + } diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java index c4860d818..a08d0897d 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java @@ -1,6 +1,7 @@ package de.danoeh.antennapod.core.feed; import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; import org.junit.Before; import org.junit.Test; @@ -10,11 +11,13 @@ import java.util.Date; import static de.danoeh.antennapod.core.feed.FeedItemMother.anyFeedItemWithImage; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; public class FeedItemTest { private static final String TEXT_LONG = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."; private static final String TEXT_SHORT = "Lorem ipsum"; + private static final long ONE_HOUR = 1000L * 3600L; private FeedItem original; private FeedItem changedFeedItem; @@ -136,4 +139,36 @@ public class FeedItemTest { item.setDescriptionIfLonger(contentEncoded); assertEquals(TEXT_LONG, item.getDescription()); } -}
\ No newline at end of file + + @Test + public void testAutoDownloadBackoff() { + FeedItem item = new FeedItem(); + item.setMedia(new FeedMedia(item, "https://example.com/file.mp3", 0, "audio/mpeg")); + + long now = ONE_HOUR; // In reality, this is System.currentTimeMillis() + assertTrue(item.isAutoDownloadable(now)); + item.increaseFailedAutoDownloadAttempts(now); + assertFalse(item.isAutoDownloadable(now)); + + now += ONE_HOUR; + assertTrue(item.isAutoDownloadable(now)); + item.increaseFailedAutoDownloadAttempts(now); + assertFalse(item.isAutoDownloadable(now)); + + now += ONE_HOUR; + assertFalse(item.isAutoDownloadable(now)); // Should backoff, so more than 1 hour needed + + now += ONE_HOUR; + assertTrue(item.isAutoDownloadable(now)); // Now it's enough + item.increaseFailedAutoDownloadAttempts(now); + item.increaseFailedAutoDownloadAttempts(now); + item.increaseFailedAutoDownloadAttempts(now); + + now += 1000L * ONE_HOUR; + assertFalse(item.isAutoDownloadable(now)); // Should have given up + item.increaseFailedAutoDownloadAttempts(now); + + now += 1000L * ONE_HOUR; + assertFalse(item.isAutoDownloadable(now)); // Still given up + } +} diff --git a/core/src/test/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdaterTest.java b/core/src/test/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdaterTest.java index 4890c471a..92c0e8e3d 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdaterTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/service/playback/PlaybackVolumeUpdaterTest.java @@ -6,6 +6,8 @@ import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.model.feed.FeedPreferences; import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.junit.Before; import org.junit.Test; diff --git a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java index 356a7f77e..552f7d70a 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/sync/GuidValidatorTest.java @@ -14,5 +14,6 @@ public class GuidValidatorTest extends TestCase { assertFalse(GuidValidator.isValidGuid("\n")); assertFalse(GuidValidator.isValidGuid(" \n")); assertFalse(GuidValidator.isValidGuid(null)); + assertFalse(GuidValidator.isValidGuid("null")); } }
\ No newline at end of file diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtilTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtilTest.java deleted file mode 100644 index dc64f6ae0..000000000 --- a/core/src/test/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtilTest.java +++ /dev/null @@ -1,56 +0,0 @@ -package de.danoeh.antennapod.core.util; - -import org.junit.Test; - -import static org.junit.Assert.assertEquals; - -/** - * Tests for {@link RewindAfterPauseUtils}. - */ -public class RewindAfterPauseUtilTest { - - @Test - public void testCalculatePositionWithRewindNoRewind() { - final int ORIGINAL_POSITION = 10000; - long lastPlayed = System.currentTimeMillis(); - int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed); - - assertEquals(ORIGINAL_POSITION, position); - } - - @Test - public void testCalculatePositionWithRewindSmallRewind() { - final int ORIGINAL_POSITION = 10000; - long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_SHORT_REWIND - 1000; - int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed); - - assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.SHORT_REWIND, position); - } - - @Test - public void testCalculatePositionWithRewindMediumRewind() { - final int ORIGINAL_POSITION = 10000; - long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_MEDIUM_REWIND - 1000; - int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed); - - assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.MEDIUM_REWIND, position); - } - - @Test - public void testCalculatePositionWithRewindLongRewind() { - final int ORIGINAL_POSITION = 30000; - long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000; - int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed); - - assertEquals(ORIGINAL_POSITION - RewindAfterPauseUtils.LONG_REWIND, position); - } - - @Test - public void testCalculatePositionWithRewindNegativeNumber() { - final int ORIGINAL_POSITION = 100; - long lastPlayed = System.currentTimeMillis() - RewindAfterPauseUtils.ELAPSED_TIME_FOR_LONG_REWIND - 1000; - int position = RewindAfterPauseUtils.calculatePositionWithRewind(ORIGINAL_POSITION, lastPlayed); - - assertEquals(0, position); - } -} |