diff options
286 files changed, 5840 insertions, 6777 deletions
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index f1d96dc7a..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve existing features -labels: 'Type: Possible bug' ---- - -# Checklist -<!-- Place an x in the boxes to tick them: [x] --> - -- [ ] I have used the search function to see if someone else has already submitted the same bug report. -- [ ] I will describe the problem with as much detail as possible. -- [ ] If the bug only to occurs with a certain podcast, I will include the URL of that podcast. - -# System info -<!-- The following information is very important to fill out because some bugs may only occur on certain devices or versions of Android. --> - -**App version**: x.y.z -<!-- The latest version may be different depending on your device. You can find the version in AntennaPod's settings. --> - -**App source**: Google Play / F-Droid / ... -<!-- Please delete irrelevant answer or fill in the blank --> - -**Android version**: 5.x (Please mention if you are using a custom rom!) - -**Device model**: - -# Bug description - -**Steps to reproduce**: -1. This -2. Then that -3. Then this -4. Etc. - -**Expected behaviour**: -<!-- After following the steps, what did you think AntennaPod would do? --> - -**Current behaviour**: -<!-- What did AntennaPod do instead? Screenshots might help. Usually, you can take a screenshot of your smartphone by pressing *Power* + *Volume down* for a few seconds. --> - -**First occurred**: (e.g. about x days/weeks ago) - -**Environment**: -<!-- Settings you have changed (e.g. Auto Download, changed media player). "Unusual" devices you use (e.g. Bluetooth headphones). --> - -**Stacktrace/Logcat**: -<!-- If you are experiencing a crash, including the stacktrace will likely get it fixed sooner. AntennaPod has an `export logs` feature for this. --> -``` -[if available] -``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..96c33d973 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,78 @@ +name: Bug report +description: Create a report to help us improve existing features +labels: ["Type: Possible bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have used the search function to see if someone else has already submitted the same bug report. + required: true + - label: I will describe the problem with as much detail as possible. + required: true + - label: If the bug only to occurs with a certain podcast, I will include the URL of that podcast. + required: true + - type: input + id: version + attributes: + label: App version + description: The latest version is different on each device, so we need the actual version number found on the settings screen. + placeholder: x.y.z + validations: + required: true + - type: dropdown + id: source + attributes: + label: Where did you get the app from + multiple: false + options: + - Google Play + - F-Droid + - Other + validations: + required: true + - type: input + id: android_version + attributes: + label: Android version + description: Please mention if you are using a custom rom! + validations: + required: true + - type: input + id: device + attributes: + label: Device model + - type: input + id: first + attributes: + label: First occurred + placeholder: about x days/weeks ago + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. This + 2. Then that + 3. Then this + 4. Etc. + - type: textarea + id: expected + attributes: + label: Expected behaviour + description: After following the steps, what did you think AntennaPod would do? + - type: textarea + id: current + attributes: + label: Current behaviour + description: What did AntennaPod do instead? Screenshots might help. Usually, you can take a screenshot of your smartphone by pressing *Power* + *Volume down* for a few seconds. + - type: textarea + id: logs + attributes: + label: Logs + description: If you are experiencing a crash, including the stacktrace will likely get it fixed sooner. AntennaPod has an `export logs` feature for this. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 24f2f5772..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -name: Feature request -about: Request a new feature or enhancement - ---- - -# Checklist -<!-- Place an x in the boxes to tick them: [x] --> - -- [ ] I have used the search function to see if someone else has already submitted the same feature request. -- [ ] I will only create one feature request per issue. -- [ ] I will describe the problem with as much detail as possible. - -# System info - -**App version**: x.y.z -<!-- The latest version may be different depending on your device. You can find the version in AntennaPod's settings. --> - -**App source**: Google Play / F-Droid / ... -<!-- Please delete irrelevant answer or fill in the blank --> - -# Feature description - -**Problem you may be having, or feature you want**: -<!-- Give a brief explanation about the problem that may currently exist --> - -**Suggested solution**: -<!-- Describe how your requested feature solves this problem. Try to be as specific as possible. Please not only explain what the feature does, but also how. --> - -**Screenshots / Drawings / Technical details**: -<!-- If your request is about (or includes) changing or extending the UI, describe what the UI would look like and how the user would interact with it. --> diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..58ac86f8b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,48 @@ +name: Feature request +description: Request a new feature or enhancement +body: + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have used the search function to see if someone else has already submitted the same feature request. + required: true + - label: I will describe the problem with as much detail as possible. + required: true + - label: This request contains only one single feature, **not** a list of multiple (related) features. + required: true + - type: input + id: version + attributes: + label: App version + description: The latest version is different on each device, so we need the actual version number found on the settings screen. + placeholder: x.y.z + validations: + required: true + - type: dropdown + id: source + attributes: + label: Where did you get the app from + multiple: false + options: + - Google Play + - F-Droid + - Other + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem you may be having, or feature you want + description: Give a brief explanation about the problem that may currently exist + - type: textarea + id: solution + attributes: + label: Suggested solution + description: Describe how your requested feature solves this problem. Try to be as specific as possible. Please not only explain what the feature does, but also how. + - type: textarea + id: screenshots + attributes: + label: Screenshots / Drawings / Technical details + description: If your request is about (or includes) changing or extending the UI, describe what the UI would look like and how the user would interact with it. diff --git a/.github/workflows/android-emulator.yml b/.github/workflows/android-emulator.yml index eed69911a..c8e66e14f 100644 --- a/.github/workflows/android-emulator.yml +++ b/.github/workflows/android-emulator.yml @@ -9,11 +9,11 @@ jobs: runs-on: macOS-latest steps: - uses: actions/checkout@v2 - - name: Set up JDK 8 + - name: Set up JDK 11 uses: actions/setup-java@v2 with: distribution: 'adopt' - java-version: '8' + java-version: '11' - name: Wrapper validation uses: gradle/wrapper-validation-action@v1 - name: Build with Gradle diff --git a/app/build.gradle b/app/build.gradle index 06d04bd49..e407390be 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,7 +1,7 @@ plugins { id('com.android.application') id('com.getkeepsafe.dexcount') - id('com.github.triplet.play') version '3.4.0' apply false + id('com.github.triplet.play') version '3.6.0-agp4.2' apply false } apply from: "../common.gradle" apply from: "../playFlavor.gradle" @@ -76,9 +76,9 @@ android { dexcount { if (project.hasProperty("enableDexcountInDebug")) { - runOnEachPackage enableDexcountInDebug.toBoolean() + runOnEachPackage = enableDexcountInDebug.toBoolean() } else { // default to not running dexcount - runOnEachPackage false + runOnEachPackage = false } } } @@ -111,10 +111,13 @@ android { dependencies { implementation project(":core") + implementation project(":event") implementation project(':model') implementation project(':net:sync:gpoddernet') implementation project(':net:sync:model') implementation project(':parser:feed') + implementation project(':playback:base') + implementation project(':playback:cast') implementation project(':ui:app-start-intent') implementation project(':ui:common') @@ -122,11 +125,13 @@ dependencies { implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0' implementation "androidx.core:core:$coreVersion" + implementation "androidx.fragment:fragment:$fragmentVersion" implementation 'androidx.gridlayout:gridlayout:1.0.0' implementation "androidx.media:media:$mediaVersion" + implementation "androidx.palette:palette:$paletteVersion" implementation "androidx.preference:preference:$preferenceVersion" - implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'androidx.viewpager2:viewpager2:1.1.0-alpha01' + implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion" + implementation "androidx.viewpager2:viewpager2:$viewPager2Version" implementation "androidx.work:work-runtime:$workManagerVersion" implementation "com.google.android.material:material:$googleMaterialVersion" diff --git a/app/src/androidTest/java/de/test/antennapod/playback/PlaybackTest.java b/app/src/androidTest/java/de/test/antennapod/playback/PlaybackTest.java index 9f7af3a16..2ab2361d7 100644 --- a/app/src/androidTest/java/de/test/antennapod/playback/PlaybackTest.java +++ b/app/src/androidTest/java/de/test/antennapod/playback/PlaybackTest.java @@ -11,6 +11,7 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; import de.danoeh.antennapod.model.feed.FeedItemFilter; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.awaitility.Awaitility; import org.hamcrest.Matcher; import org.junit.After; @@ -32,7 +33,6 @@ import de.danoeh.antennapod.model.feed.FeedMedia; 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.PlayerStatus; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.util.IntentUtils; diff --git a/app/src/androidTest/java/de/test/antennapod/service/playback/CancelablePSMPCallback.java b/app/src/androidTest/java/de/test/antennapod/service/playback/CancelablePSMPCallback.java index ae38fd5e7..4d57b9b43 100644 --- a/app/src/androidTest/java/de/test/antennapod/service/playback/CancelablePSMPCallback.java +++ b/app/src/androidTest/java/de/test/antennapod/service/playback/CancelablePSMPCallback.java @@ -1,9 +1,10 @@ package de.test.antennapod.service.playback; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCallback { @@ -35,22 +36,6 @@ public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCa } @Override - public void playbackSpeedChanged(float s) { - if (isCancelled) { - return; - } - originalCallback.playbackSpeedChanged(s); - } - - @Override - public void onBufferingUpdate(int percent) { - if (isCancelled) { - return; - } - originalCallback.onBufferingUpdate(percent); - } - - @Override public void onMediaChanged(boolean reloadUI) { if (isCancelled) { return; @@ -59,22 +44,6 @@ public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCa } @Override - public boolean onMediaPlayerInfo(int code, int resourceId) { - if (isCancelled) { - return true; - } - return originalCallback.onMediaPlayerInfo(code, resourceId); - } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - if (isCancelled) { - return true; - } - return originalCallback.onMediaPlayerError(inObj, what, extra); - } - - @Override public void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext) { if (isCancelled) { return; @@ -106,6 +75,15 @@ public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCa return originalCallback.getNextInQueue(currentMedia); } + @Nullable + @Override + public Playable findMedia(@NonNull String url) { + if (isCancelled) { + return null; + } + return originalCallback.findMedia(url); + } + @Override public void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) { if (isCancelled) { @@ -113,4 +91,12 @@ public class CancelablePSMPCallback implements PlaybackServiceMediaPlayer.PSMPCa } originalCallback.onPlaybackEnded(mediaType, stopPlaying); } + + @Override + public void ensureMediaInfoLoaded(@NonNull Playable media) { + if (isCancelled) { + return; + } + originalCallback.ensureMediaInfoLoaded(media); + } }
\ No newline at end of file diff --git a/app/src/androidTest/java/de/test/antennapod/service/playback/DefaultPSMPCallback.java b/app/src/androidTest/java/de/test/antennapod/service/playback/DefaultPSMPCallback.java index 29a854f20..fb55c7ad0 100644 --- a/app/src/androidTest/java/de/test/antennapod/service/playback/DefaultPSMPCallback.java +++ b/app/src/androidTest/java/de/test/antennapod/service/playback/DefaultPSMPCallback.java @@ -1,69 +1,59 @@ package de.test.antennapod.service.playback; import androidx.annotation.NonNull; -import androidx.annotation.StringRes; +import androidx.annotation.Nullable; import de.danoeh.antennapod.model.playback.MediaType; -import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; public class DefaultPSMPCallback implements PlaybackServiceMediaPlayer.PSMPCallback { - @Override - public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { + @Override + public void statusChanged(PlaybackServiceMediaPlayer.PSMPInfo newInfo) { - } + } - @Override - public void shouldStop() { + @Override + public void shouldStop() { - } + } - @Override - public void playbackSpeedChanged(float s) { + @Override + public void onMediaChanged(boolean reloadUI) { - } + } - @Override - public void onBufferingUpdate(int percent) { + @Override + public void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext) { - } + } - @Override - public void onMediaChanged(boolean reloadUI) { + @Override + public void onPlaybackStart(@NonNull Playable playable, int position) { - } + } - @Override - public boolean onMediaPlayerInfo(int code, @StringRes int resourceId) { - return false; - } + @Override + public void onPlaybackPause(Playable playable, int position) { - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - return false; - } + } - @Override - public void onPostPlayback(@NonNull Playable media, boolean ended, boolean skipped, boolean playingNext) { + @Override + public Playable getNextInQueue(Playable currentMedia) { + return null; + } - } + @Nullable + @Override + public Playable findMedia(@NonNull String url) { + return null; + } - @Override - public void onPlaybackStart(@NonNull Playable playable, int position) { + @Override + public void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) { - } + } - @Override - public void onPlaybackPause(Playable playable, int position) { - - } - - @Override - public Playable getNextInQueue(Playable currentMedia) { - return null; - } - - @Override - public void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) { - - } - }
\ No newline at end of file + @Override + public void ensureMediaInfoLoaded(@NonNull Playable media) { + } +}
\ No newline at end of file diff --git a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java index dfb0e3e36..0d05c7624 100644 --- a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java +++ b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceMediaPlayerTest.java @@ -2,9 +2,12 @@ package de.test.antennapod.service.playback; import android.content.Context; +import androidx.test.annotation.UiThreadTest; import androidx.test.filters.MediumTest; import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; import de.test.antennapod.EspressoTestUtils; import junit.framework.AssertionFailedError; @@ -24,8 +27,6 @@ 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.service.playback.LocalPSMP; -import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.core.storage.PodDBAdapter; import de.danoeh.antennapod.model.playback.Playable; import de.test.antennapod.util.service.download.HTTPBin; @@ -56,12 +57,14 @@ public class PlaybackServiceMediaPlayerTest { private volatile AssertionFailedError assertionError; @After + @UiThreadTest public void tearDown() throws Exception { PodDBAdapter.deleteDatabase(); httpServer.stop(); } @Before + @UiThreadTest public void setUp() throws Exception { assertionError = null; EspressoTestUtils.clearPreferences(); @@ -117,6 +120,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testInit() { final Context c = getInstrumentation().getTargetContext(); PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, new DefaultPSMPCallback()); @@ -141,6 +145,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectStreamNoStartNoPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(2); @@ -181,6 +186,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectStreamStartNoPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(2); @@ -222,6 +228,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectStreamNoStartPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(4); @@ -264,6 +271,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectStreamStartPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(5); @@ -308,6 +316,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectLocalNoStartNoPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(2); @@ -347,6 +356,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectLocalStartNoPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(2); @@ -386,6 +396,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectLocalNoStartPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(4); @@ -427,6 +438,7 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPlayMediaObjectLocalStartPrepare() throws InterruptedException { final Context c = getInstrumentation().getTargetContext(); final CountDownLatch countDownLatch = new CountDownLatch(5); @@ -514,13 +526,6 @@ public class PlaybackServiceMediaPlayerTest { if (assertionError == null) assertionError = new AssertionFailedError("Unexpected call to shouldStop"); } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - if (assertionError == null) - assertionError = new AssertionFailedError("Unexpected call to onMediaPlayerError"); - return false; - } }); PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(playableFileUrl, PLAYABLE_LOCAL_URL); @@ -537,46 +542,55 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPauseDefaultState() throws InterruptedException { pauseTestSkeleton(PlayerStatus.STOPPED, false, false, false, 1); } @Test + @UiThreadTest public void testPausePlayingStateNoAbandonNoReinitNoStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, false, false, false, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateNoAbandonNoReinitStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, true, false, false, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateAbandonNoReinitNoStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, false, true, false, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateAbandonNoReinitStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, true, true, false, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateNoAbandonReinitNoStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, false, false, true, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateNoAbandonReinitStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, true, false, true, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateAbandonReinitNoStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, false, true, true, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPausePlayingStateAbandonReinitStream() throws InterruptedException { pauseTestSkeleton(PlayerStatus.PLAYING, true, true, true, LATCH_TIMEOUT_SECONDS); } @@ -604,14 +618,6 @@ public class PlaybackServiceMediaPlayerTest { } } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - if (assertionError == null) { - assertionError = new AssertionFailedError("Unexpected call of onMediaPlayerError"); - } - return false; - } }); PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); if (initialState == PlayerStatus.PREPARED || initialState == PlayerStatus.PLAYING || initialState == PlayerStatus.PAUSED) { @@ -631,16 +637,19 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testResumePausedState() throws InterruptedException { resumeTestSkeleton(PlayerStatus.PAUSED, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testResumePreparedState() throws InterruptedException { resumeTestSkeleton(PlayerStatus.PREPARED, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testResumePlayingState() throws InterruptedException { resumeTestSkeleton(PlayerStatus.PLAYING, 1); } @@ -664,13 +673,6 @@ public class PlaybackServiceMediaPlayerTest { } } } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - if (assertionError == null) - assertionError = new AssertionFailedError("Unexpected call to onMediaPlayerError"); - return false; - } }); PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(playableFileUrl, PLAYABLE_LOCAL_URL); @@ -700,21 +702,25 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testPrepareInitializedState() throws InterruptedException { prepareTestSkeleton(PlayerStatus.INITIALIZED, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPreparePlayingState() throws InterruptedException { prepareTestSkeleton(PlayerStatus.PLAYING, 1); } @Test + @UiThreadTest public void testPreparePausedState() throws InterruptedException { prepareTestSkeleton(PlayerStatus.PAUSED, 1); } @Test + @UiThreadTest public void testPreparePreparedState() throws InterruptedException { prepareTestSkeleton(PlayerStatus.PREPARED, 1); } @@ -738,13 +744,6 @@ public class PlaybackServiceMediaPlayerTest { } } } - - @Override - public boolean onMediaPlayerError(Object inObj, int what, int extra) { - if (assertionError == null) - assertionError = new AssertionFailedError("Unexpected call to onMediaPlayerError"); - return false; - } }); PlaybackServiceMediaPlayer psmp = new LocalPSMP(c, callback); Playable p = writeTestPlayable(playableFileUrl, PLAYABLE_LOCAL_URL); @@ -764,21 +763,25 @@ public class PlaybackServiceMediaPlayerTest { } @Test + @UiThreadTest public void testReinitPlayingState() throws InterruptedException { reinitTestSkeleton(PlayerStatus.PLAYING, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testReinitPausedState() throws InterruptedException { reinitTestSkeleton(PlayerStatus.PAUSED, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testPreparedPlayingState() throws InterruptedException { reinitTestSkeleton(PlayerStatus.PREPARED, LATCH_TIMEOUT_SECONDS); } @Test + @UiThreadTest public void testReinitInitializedState() throws InterruptedException { reinitTestSkeleton(PlayerStatus.INITIALIZED, LATCH_TIMEOUT_SECONDS); } diff --git a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceTaskManagerTest.java b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceTaskManagerTest.java index 7803144e1..013d4db50 100644 --- a/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceTaskManagerTest.java +++ b/app/src/androidTest/java/de/test/antennapod/service/playback/PlaybackServiceTaskManagerTest.java @@ -5,10 +5,12 @@ import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.annotation.UiThreadTest; import androidx.test.filters.LargeTest; +import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.widget.WidgetUpdater; import org.awaitility.Awaitility; import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -19,7 +21,7 @@ import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; -import de.danoeh.antennapod.core.event.QueueEvent; +import de.danoeh.antennapod.event.QueueEvent; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; @@ -144,7 +146,7 @@ public class PlaybackServiceTaskManagerTest { FeedItem item = DBReader.getFeedItem(testItem.getId()); item.getMedia().setDownloaded(true); item.getMedia().setFile_url("file://123"); - item.setAutoDownload(false); + item.disableAutoDownload(); DBWriter.setFeedMedia(item.getMedia()).get(); DBWriter.setFeedItem(item).get(); @@ -173,21 +175,6 @@ public class PlaybackServiceTaskManagerTest { } @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - - } - - @Override - public void onSleepTimerExpired() { - - } - - @Override - public void onSleepTimerReset() { - - } - - @Override public WidgetUpdater.WidgetState requestWidgetState() { return null; } @@ -234,21 +221,6 @@ public class PlaybackServiceTaskManagerTest { } @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - - } - - @Override - public void onSleepTimerExpired() { - - } - - @Override - public void onSleepTimerReset() { - - } - - @Override public WidgetUpdater.WidgetState requestWidgetState() { countDownLatch.countDown(); return null; @@ -325,42 +297,20 @@ public class PlaybackServiceTaskManagerTest { final long TIME = 2000; final long TIMEOUT = 2 * TIME; final CountDownLatch countDownLatch = new CountDownLatch(1); - PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, new PlaybackServiceTaskManager.PSTMCallback() { - @Override - public void positionSaverTick() { - - } - - @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - - } - - @Override - public void onSleepTimerExpired() { + Object timerReceiver = new Object() { + @Subscribe + public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { if (countDownLatch.getCount() == 0) { fail(); } countDownLatch.countDown(); } - - @Override - public void onSleepTimerReset() { - - } - - @Override - public WidgetUpdater.WidgetState requestWidgetState() { - return null; - } - - @Override - public void onChapterLoaded(Playable media) { - - } - }); + }; + EventBus.getDefault().register(timerReceiver); + PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM); pstm.setSleepTimer(TIME); countDownLatch.await(TIMEOUT, TimeUnit.MILLISECONDS); + EventBus.getDefault().unregister(timerReceiver); pstm.shutdown(); } @@ -368,44 +318,26 @@ public class PlaybackServiceTaskManagerTest { @UiThreadTest public void testDisableSleepTimer() throws InterruptedException { final Context c = InstrumentationRegistry.getInstrumentation().getTargetContext(); - final long TIME = 1000; + final long TIME = 5000; final long TIMEOUT = 2 * TIME; final CountDownLatch countDownLatch = new CountDownLatch(1); - PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, new PlaybackServiceTaskManager.PSTMCallback() { - @Override - public void positionSaverTick() { - - } - - @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - - } - - @Override - public void onSleepTimerExpired() { - fail("Sleeptimer expired"); - } - - @Override - public void onSleepTimerReset() { - - } - - @Override - public WidgetUpdater.WidgetState requestWidgetState() { - return null; - } - - @Override - public void onChapterLoaded(Playable media) { - + Object timerReceiver = new Object() { + @Subscribe + public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { + if (event.isOver()) { + countDownLatch.countDown(); + } else if (event.getTimeLeft() == 1) { + fail("Arrived at 1 but should have been cancelled"); + } } - }); + }; + PlaybackServiceTaskManager pstm = new PlaybackServiceTaskManager(c, defaultPSTM); + EventBus.getDefault().register(timerReceiver); pstm.setSleepTimer(TIME); pstm.disableSleepTimer(); assertFalse(countDownLatch.await(TIMEOUT, TimeUnit.MILLISECONDS)); pstm.shutdown(); + EventBus.getDefault().unregister(timerReceiver); } @Test @@ -436,21 +368,6 @@ public class PlaybackServiceTaskManagerTest { } @Override - public void onSleepTimerAlmostExpired(long timeLeft) { - - } - - @Override - public void onSleepTimerExpired() { - - } - - @Override - public void onSleepTimerReset() { - - } - - @Override public WidgetUpdater.WidgetState requestWidgetState() { return null; } diff --git a/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java b/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java index c71bff357..74414240f 100644 --- a/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java +++ b/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java @@ -126,8 +126,9 @@ public class PreferencesTest { clickPreference(R.string.user_interface_label); String[] buttons = res.getStringArray(R.array.compact_notification_buttons_options); clickPreference(R.string.pref_compact_notification_buttons_title); - // First uncheck checkbox - onView(withText(buttons[2])).perform(click()); + // First uncheck checkboxes + onView(withText(buttons[0])).perform(click()); + onView(withText(buttons[1])).perform(click()); // Now try to check all checkboxes onView(withText(buttons[0])).perform(click()); diff --git a/app/src/androidTest/java/de/test/antennapod/ui/UITestUtils.java b/app/src/androidTest/java/de/test/antennapod/ui/UITestUtils.java index b25f957d3..eedb2d9de 100644 --- a/app/src/androidTest/java/de/test/antennapod/ui/UITestUtils.java +++ b/app/src/androidTest/java/de/test/antennapod/ui/UITestUtils.java @@ -2,8 +2,8 @@ package de.test.antennapod.ui; import android.content.Context; import android.util.Log; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.QueueEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.QueueEvent; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; diff --git a/app/src/androidTest/java/de/test/antennapod/util/event/FeedItemEventListener.java b/app/src/androidTest/java/de/test/antennapod/util/event/FeedItemEventListener.java index 601bba853..7e8fc1205 100644 --- a/app/src/androidTest/java/de/test/antennapod/util/event/FeedItemEventListener.java +++ b/app/src/androidTest/java/de/test/antennapod/util/event/FeedItemEventListener.java @@ -8,7 +8,7 @@ import org.greenrobot.eventbus.Subscribe; import java.util.ArrayList; import java.util.List; -import de.danoeh.antennapod.core.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedItemEvent; import io.reactivex.functions.Consumer; /** diff --git a/app/src/free/java/de/danoeh/antennapod/config/CastCallbackImpl.java b/app/src/free/java/de/danoeh/antennapod/config/CastCallbackImpl.java deleted file mode 100644 index fb23dfa1a..000000000 --- a/app/src/free/java/de/danoeh/antennapod/config/CastCallbackImpl.java +++ /dev/null @@ -1,7 +0,0 @@ -package de.danoeh.antennapod.config; - -import de.danoeh.antennapod.core.CastCallbacks; - -class CastCallbackImpl implements CastCallbacks { - -} diff --git a/app/src/free/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java b/app/src/free/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java deleted file mode 100644 index e096f883f..000000000 --- a/app/src/free/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java +++ /dev/null @@ -1,14 +0,0 @@ -package de.danoeh.antennapod.preferences; - -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.fragment.preferences.PlaybackPreferencesFragment; - -/** - * Implements functions from PreferenceController that are flavor dependent. - */ -public class PreferenceControllerFlavorHelper { - - public static void setupFlavoredUI(PlaybackPreferencesFragment ui) { - ui.findPreference(UserPreferences.PREF_CAST_ENABLED).setEnabled(false); - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 47648f9d3..0f8242e63 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,7 +40,8 @@ android:supportsRtl="true" android:logo="@mipmap/ic_launcher" android:resizeableActivity="true" - android:allowAudioPlaybackCapture="true"> + android:allowAudioPlaybackCapture="true" + android:networkSecurityConfig="@xml/network_security_config"> <meta-data android:name="android.webkit.WebView.MetricsOptOut" @@ -53,6 +54,9 @@ <meta-data android:name="com.google.android.backup.api_key" android:value="AEdPqrEAAAAI3a05VToCTlqBymJrbFGaKQMvF-bBAuLsOdavBA"/> + <meta-data + android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME" + android:value="de.danoeh.antennapod.playback.cast.CastOptionsProvider" /> <!-- Version < 3.0. DeX Mode and Screen Mirroring support --> <meta-data android:name="com.samsung.android.keepalive.density" android:value="true"/> @@ -65,15 +69,13 @@ android:configChanges="keyboardHidden|orientation|screenSize" android:exported="true"> <intent-filter> - <action android:name="android.intent.action.MAIN"/> - <category android:name="android.intent.category.LAUNCHER"/> - </intent-filter> + <action android:name="android.intent.action.MAIN" /> + <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> + <action android:name="android.intent.action.MUSIC_PLAYER" /> - <intent-filter> - <action android:name= - "android.media.action.MEDIA_PLAY_FROM_SEARCH" /> - <category android:name= - "android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.APP_MUSIC" /> </intent-filter> <meta-data @@ -98,13 +100,6 @@ android:host="antennapod.org" android:pathPrefix="/deeplink/main" android:scheme="https" /> - </intent-filter> - <intent-filter> - <action android:name="android.intent.action.VIEW" /> - - <category android:name="android.intent.category.DEFAULT" /> - <category android:name="android.intent.category.BROWSABLE" /> - <data android:host="antennapod.org" android:pathPrefix="/deeplink/search" @@ -144,11 +139,7 @@ android:exported="true"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> - </intent-filter> - <intent-filter> <action android:name="de.danoeh.antennapod.FORCE_WIDGET_UPDATE"/> - </intent-filter> - <intent-filter> <action android:name="de.danoeh.antennapod.STOP_WIDGET_UPDATE"/> </intent-filter> <meta-data @@ -165,6 +156,7 @@ android:exported="true"> <intent-filter> <action android:name="android.intent.action.VIEW"/> + <action android:name="android.intent.action.SEND"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> @@ -172,27 +164,15 @@ <data android:mimeType="text/xml"/> <data android:mimeType="text/x-opml"/> <data android:mimeType="application/xml"/> - <data android:mimeType="application/octet-stream"/> <data android:scheme="file"/> <data android:scheme="content"/> - - <data android:host="*"/> - </intent-filter> - <intent-filter> - <action android:name="android.intent.action.SEND"/> - - <category android:name="android.intent.category.DEFAULT"/> - <category android:name="android.intent.category.BROWSABLE"/> - - <data android:mimeType="text/xml"/> - <data android:mimeType="text/plain"/> - <data android:mimeType="text/x-opml"/> - <data android:mimeType="application/xml"/> - <data android:mimeType="application/octet-stream"/> - <data android:scheme="http"/> <data android:scheme="https"/> + + <data android:host="*"/> + <data android:pathPattern=".*.xml" /> + <data android:pathPattern=".*.opml" /> </intent-filter> </activity> <activity @@ -315,6 +295,18 @@ </intent-filter> <intent-filter> + <action android:name="android.intent.action.VIEW" /> + + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + + <data android:pathPattern="/.*/podcast/.*" /> + <data android:host="podcasts.apple.com" /> + <data android:scheme="http" /> + <data android:scheme="https" /> + </intent-filter> + + <intent-filter> <action android:name="android.intent.action.SEND"/> <category android:name="android.intent.category.DEFAULT"/> @@ -324,6 +316,16 @@ </activity> + <activity android:name=".activity.SelectSubscriptionActivity" + android:label="@string/shortcut_subscription_label" + android:icon="@drawable/ic_folder_shortcut" + android:theme="@style/Theme.AntennaPod.Dark.Translucent" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.CREATE_SHORTCUT" /> + </intent-filter> + </activity> + <receiver android:name=".receiver.ConnectivityActionReceiver" android:exported="true"> diff --git a/app/src/main/java/de/danoeh/antennapod/activity/BugReportActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/BugReportActivity.java index aa59e4e96..f7c96a93a 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/BugReportActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/BugReportActivity.java @@ -7,7 +7,6 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.util.Log; import com.google.android.material.snackbar.Snackbar; @@ -103,22 +102,21 @@ public class BugReportActivity extends AppCompatActivity { Runtime.getRuntime().exec(cmd); //share file try { - Intent i = new Intent(Intent.ACTION_SEND); - i.setType("text/*"); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/*"); String authString = getString(de.danoeh.antennapod.core.R.string.provider_authority); Uri fileUri = FileProvider.getUriForFile(this, authString, filename); - i.putExtra(Intent.EXTRA_STREAM, fileUri); - i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { - PackageManager pm = getPackageManager(); - List<ResolveInfo> resInfos = pm.queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfos) { - String packageName = resolveInfo.activityInfo.packageName; - grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } + intent.putExtra(Intent.EXTRA_STREAM, fileUri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); String chooserTitle = getString(de.danoeh.antennapod.core.R.string.share_file_label); - startActivity(Intent.createChooser(i, chooserTitle)); + Intent chooser = Intent.createChooser(intent, chooserTitle); + List<ResolveInfo> resInfos = getPackageManager() + .queryIntentActivities(chooser, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfos) { + String packageName = resolveInfo.activityInfo.packageName; + grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + startActivity(chooser); } catch (Exception e) { e.printStackTrace(); int strResId = R.string.log_file_share_exception; diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java index f07ad6ad5..3e4782f1f 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -27,7 +27,6 @@ import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; -import androidx.core.view.ViewCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -38,6 +37,7 @@ import com.bumptech.glide.Glide; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.snackbar.Snackbar; +import de.danoeh.antennapod.playback.cast.CastEnabledActivity; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.Validate; import org.greenrobot.eventbus.EventBus; @@ -45,7 +45,7 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.event.MessageEvent; +import de.danoeh.antennapod.event.MessageEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; import de.danoeh.antennapod.core.service.playback.PlaybackService; @@ -152,14 +152,14 @@ public class MainActivity extends CastEnabledActivity { } /** - * ViewCompat.generateViewId stores the current ID in a static variable. + * View.generateViewId stores the current ID in a static variable. * When the process is killed, the variable gets reset. * This makes sure that we do not get ID collisions * and therefore errors when trying to restore state from another view. */ @SuppressWarnings("StatementWithEmptyBody") private void ensureGeneratedViewIdGreaterThan(int minimum) { - while (ViewCompat.generateViewId() <= minimum) { + while (View.generateViewId() <= minimum) { // Generate new IDs } } @@ -167,7 +167,7 @@ public class MainActivity extends CastEnabledActivity { @Override protected void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - outState.putInt(KEY_GENERATED_VIEW_ID, ViewCompat.generateViewId()); + outState.putInt(KEY_GENERATED_VIEW_ID, View.generateViewId()); } private final BottomSheetBehavior.BottomSheetCallback bottomSheetCallback = @@ -624,6 +624,7 @@ public class MainActivity extends CastEnabledActivity { AudioManager audioManager = (AudioManager) getSystemService(AUDIO_SERVICE); Integer customKeyCode = null; + EventBus.getDefault().post(event); switch (keyCode) { case KeyEvent.KEYCODE_P: diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java index 4dca1fda7..9148a9949 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/OnlineFeedViewActivity.java @@ -6,7 +6,6 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.LightingColorFilter; -import android.os.Build; import android.os.Bundle; import android.text.Spannable; import android.text.SpannableString; @@ -19,6 +18,7 @@ import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.UiThread; @@ -31,8 +31,8 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.adapter.FeedItemlistDescriptionAdapter; import de.danoeh.antennapod.core.dialog.DownloadRequestErrorDialogCreator; import de.danoeh.antennapod.core.event.DownloadEvent; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.glide.FastBlurTransformation; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; @@ -60,7 +60,6 @@ import de.danoeh.antennapod.dialog.AuthenticationDialog; import de.danoeh.antennapod.discovery.PodcastSearcherRegistry; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedPreferences; -import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; import de.danoeh.antennapod.model.playback.RemoteMedia; import de.danoeh.antennapod.parser.feed.UnsupportedFeedtypeException; import io.reactivex.Maybe; @@ -101,6 +100,8 @@ public class OnlineFeedViewActivity extends AppCompatActivity { private Feed feed; private String selectedDownloadUrl; private Downloader downloader; + private String username = null; + private String password = null; private boolean isPaused; private boolean didPressSubscribe = false; @@ -144,12 +145,11 @@ public class OnlineFeedViewActivity extends AppCompatActivity { if (feedUrl.contains("subscribeonandroid.com")) { feedUrl = feedUrl.replaceFirst("((www.)?(subscribeonandroid.com/))", ""); } - if (savedInstanceState == null) { - lookupUrlAndDownload(feedUrl, null, null); - } else { - lookupUrlAndDownload(feedUrl, savedInstanceState.getString("username"), - savedInstanceState.getString("password")); + if (savedInstanceState != null) { + username = savedInstanceState.getString("username"); + password = savedInstanceState.getString("password"); } + lookupUrlAndDownload(feedUrl); } } @@ -210,10 +210,8 @@ public class OnlineFeedViewActivity extends AppCompatActivity { @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - if (feed != null && feed.getPreferences() != null) { - outState.putString("username", feed.getPreferences().getUsername()); - outState.putString("password", feed.getPreferences().getPassword()); - } + outState.putString("username", username); + outState.putString("password", password); } private void resetIntent(String url) { @@ -242,32 +240,23 @@ public class OnlineFeedViewActivity extends AppCompatActivity { return super.onOptionsItemSelected(item); } - private void lookupUrlAndDownload(String url, String username, String password) { + private void lookupUrlAndDownload(String url) { download = PodcastSearcherRegistry.lookupUrl(url) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) - .subscribe(lookedUpUrl -> startFeedDownload(lookedUpUrl, username, password), + .subscribe(this::startFeedDownload, error -> { showNoPodcastFoundError(); Log.e(TAG, Log.getStackTraceString(error)); }); } - private void startFeedDownload(String url, String username, String password) { + private void startFeedDownload(String url) { Log.d(TAG, "Starting feed download"); url = URLChecker.prepareURL(url); feed = new Feed(url, null); - if (username != null && password != null) { - feed.setPreferences(new FeedPreferences(0, false, FeedPreferences.AutoDeleteAction.GLOBAL, - VolumeAdaptionSetting.OFF, username, password)); - } - String fileUrl; - try { - fileUrl = DownloadRequester.getInstance().getDownloadPathForFeed(feed).getAbsolutePath(); - } catch (DownloadRequestException e) { - e.printStackTrace(); - fileUrl = new File(getCacheDir(), FileNameGenerator.generateFileName(feed.getDownload_url())).toString(); - } + String fileUrl = new File(getExternalCacheDir(), + FileNameGenerator.generateFileName(feed.getDownload_url())).toString(); feed.setFile_url(fileUrl); final DownloadRequest request = new DownloadRequest(feed.getFile_url(), feed.getDownload_url(), "OnlineFeed", 0, Feed.FEEDFILETYPE_FEED, username, password, @@ -293,6 +282,9 @@ public class OnlineFeedViewActivity extends AppCompatActivity { parseFeed(); } else if (status.getReason() == DownloadError.ERROR_UNAUTHORIZED) { if (!isFinishing() && !isPaused) { + if (username != null && password != null) { + Toast.makeText(this, R.string.download_error_unauthorized, Toast.LENGTH_LONG).show(); + } dialog = new FeedViewAuthenticationDialog(OnlineFeedViewActivity.this, R.string.authentication_notification_title, downloader.getDownloadRequest().getSource()).create(); @@ -458,8 +450,7 @@ public class OnlineFeedViewActivity extends AppCompatActivity { final int MAX_LINES_COLLAPSED = 10; description.setMaxLines(MAX_LINES_COLLAPSED); description.setOnClickListener(v -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN - && description.getMaxLines() > MAX_LINES_COLLAPSED) { + if (description.getMaxLines() > MAX_LINES_COLLAPSED) { description.setMaxLines(MAX_LINES_COLLAPSED); } else { description.setMaxLines(2000); @@ -642,21 +633,17 @@ public class OnlineFeedViewActivity extends AppCompatActivity { if (urls.size() == 1) { // Skip dialog and display the item directly resetIntent(urls.get(0)); - startFeedDownload(urls.get(0), null, null); + startFeedDownload(urls.get(0)); return true; } - final ArrayAdapter<String> adapter = new ArrayAdapter<>(OnlineFeedViewActivity.this, R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles); + final ArrayAdapter<String> adapter = new ArrayAdapter<>(OnlineFeedViewActivity.this, + R.layout.ellipsize_start_listitem, R.id.txtvTitle, titles); DialogInterface.OnClickListener onClickListener = (dialog, which) -> { String selectedUrl = urls.get(which); dialog.dismiss(); resetIntent(selectedUrl); - FeedPreferences prefs = feed.getPreferences(); - if(prefs != null) { - startFeedDownload(selectedUrl, prefs.getUsername(), prefs.getPassword()); - } else { - startFeedDownload(selectedUrl, null, null); - } + startFeedDownload(selectedUrl); }; AlertDialog.Builder ab = new AlertDialog.Builder(OnlineFeedViewActivity.this) @@ -679,7 +666,7 @@ public class OnlineFeedViewActivity extends AppCompatActivity { private final String feedUrl; FeedViewAuthenticationDialog(Context context, int titleRes, String feedUrl) { - super(context, titleRes, true, null, null); + super(context, titleRes, true, username, password); this.feedUrl = feedUrl; } @@ -691,7 +678,9 @@ public class OnlineFeedViewActivity extends AppCompatActivity { @Override protected void onConfirmed(String username, String password) { - startFeedDownload(feedUrl, username, password); + OnlineFeedViewActivity.this.username = username; + OnlineFeedViewActivity.this.password = password; + startFeedDownload(feedUrl); } } diff --git a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportActivity.java index a6810715c..3d0c9d113 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/OpmlImportActivity.java @@ -1,5 +1,6 @@ package de.danoeh.antennapod.activity; +import android.Manifest; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; @@ -15,7 +16,9 @@ import android.view.View; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.Toast; -import androidx.annotation.NonNull; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; @@ -35,7 +38,6 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; import org.apache.commons.io.ByteOrderMark; import org.apache.commons.io.input.BOMInputStream; -import org.apache.commons.lang3.ArrayUtils; import java.io.InputStream; import java.io.InputStreamReader; @@ -48,7 +50,6 @@ import java.util.List; * */ public class OpmlImportActivity extends AppCompatActivity { private static final String TAG = "OpmlImportBaseActivity"; - private static final int PERMISSION_REQUEST_READ_EXTERNAL_STORAGE = 5; @Nullable private Uri uri; OpmlSelectionBinding viewBinding; private ArrayAdapter<String> listAdapter; @@ -198,27 +199,23 @@ public class OpmlImportActivity extends AppCompatActivity { } private void requestPermission() { - String[] permissions = { android.Manifest.permission.READ_EXTERNAL_STORAGE }; - ActivityCompat.requestPermissions(this, permissions, PERMISSION_REQUEST_READ_EXTERNAL_STORAGE); + requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE); } - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, - @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - if (requestCode != PERMISSION_REQUEST_READ_EXTERNAL_STORAGE) { - return; - } - if (grantResults.length > 0 && ArrayUtils.contains(grantResults, PackageManager.PERMISSION_GRANTED)) { - startImport(); - } else { - new AlertDialog.Builder(this) - .setMessage(R.string.opml_import_ask_read_permission) - .setPositiveButton(android.R.string.ok, (dialog, which) -> requestPermission()) - .setNegativeButton(R.string.cancel_label, (dialog, which) -> finish()) - .show(); - } - } + private final ActivityResultLauncher<String> requestPermissionLauncher = + registerForActivityResult(new RequestPermission(), isGranted -> { + if (isGranted) { + startImport(); + } else { + new AlertDialog.Builder(this) + .setMessage(R.string.opml_import_ask_read_permission) + .setPositiveButton(android.R.string.ok, (dialog, which) -> + requestPermission()) + .setNegativeButton(R.string.cancel_label, (dialog, which) -> + finish()) + .show(); + } + }); /** Starts the import process. */ private void startImport() { diff --git a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java index 600204554..1fc16ab32 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/PreferenceActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.provider.Settings; import android.view.Menu; import android.view.MenuItem; + import android.view.View; import android.view.inputmethod.InputMethodManager; @@ -21,13 +22,13 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.databinding.SettingsActivityBinding; import de.danoeh.antennapod.fragment.preferences.AutoDownloadPreferencesFragment; -import de.danoeh.antennapod.fragment.preferences.GpodderPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.ImportExportPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.MainPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.NetworkPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.NotificationPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.PlaybackPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.StoragePreferencesFragment; +import de.danoeh.antennapod.fragment.preferences.synchronization.SynchronizationPreferencesFragment; import de.danoeh.antennapod.fragment.preferences.SwipePreferencesFragment; import de.danoeh.antennapod.fragment.preferences.UserInterfacePreferencesFragment; @@ -76,8 +77,8 @@ public class PreferenceActivity extends AppCompatActivity implements SearchPrefe prefFragment = new ImportExportPreferencesFragment(); } else if (screen == R.xml.preferences_autodownload) { prefFragment = new AutoDownloadPreferencesFragment(); - } else if (screen == R.xml.preferences_gpodder) { - prefFragment = new GpodderPreferencesFragment(); + } else if (screen == R.xml.preferences_synchronization) { + prefFragment = new SynchronizationPreferencesFragment(); } else if (screen == R.xml.preferences_playback) { prefFragment = new PlaybackPreferencesFragment(); } else if (screen == R.xml.preferences_notifications) { @@ -101,8 +102,8 @@ public class PreferenceActivity extends AppCompatActivity implements SearchPrefe return R.string.import_export_pref; } else if (preferences == R.xml.preferences_user_interface) { return R.string.user_interface_label; - } else if (preferences == R.xml.preferences_gpodder) { - return R.string.gpodnet_main_label; + } else if (preferences == R.xml.preferences_synchronization) { + return R.string.synchronization_pref; } else if (preferences == R.xml.preferences_notifications) { return R.string.notification_pref_fragment; } else if (preferences == R.xml.feed_settings) { diff --git a/app/src/main/java/de/danoeh/antennapod/activity/SelectSubscriptionActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/SelectSubscriptionActivity.java new file mode 100644 index 000000000..4ffed949e --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/activity/SelectSubscriptionActivity.java @@ -0,0 +1,162 @@ +package de.danoeh.antennapod.activity; + +import static de.danoeh.antennapod.activity.MainActivity.EXTRA_FEED_ID; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.Log; +import android.widget.ArrayAdapter; +import android.widget.ListView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.pm.ShortcutInfoCompat; +import androidx.core.content.pm.ShortcutManagerCompat; +import androidx.core.graphics.drawable.IconCompat; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.DataSource; +import com.bumptech.glide.load.engine.GlideException; +import com.bumptech.glide.request.RequestListener; +import com.bumptech.glide.request.RequestOptions; +import com.bumptech.glide.request.target.Target; + +import java.util.ArrayList; +import java.util.List; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.NavDrawerData; +import de.danoeh.antennapod.databinding.SubscriptionSelectionActivityBinding; +import de.danoeh.antennapod.model.feed.Feed; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class SelectSubscriptionActivity extends AppCompatActivity { + + private static final String TAG = "SelectSubscription"; + + private Disposable disposable; + private volatile List<Feed> listItems; + + private SubscriptionSelectionActivityBinding viewBinding; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + setTheme(UserPreferences.getTranslucentTheme()); + super.onCreate(savedInstanceState); + + viewBinding = SubscriptionSelectionActivityBinding.inflate(getLayoutInflater()); + setContentView(viewBinding.getRoot()); + setSupportActionBar(viewBinding.toolbar); + setTitle(R.string.shortcut_select_subscription); + + viewBinding.transparentBackground.setOnClickListener(v -> finish()); + viewBinding.card.setOnClickListener(null); + + loadSubscriptions(); + + final Integer[] checkedPosition = new Integer[1]; + viewBinding.list.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + viewBinding.list.setOnItemClickListener((listView, view1, position, rowId) -> + checkedPosition[0] = position + ); + viewBinding.shortcutBtn.setOnClickListener(view -> { + if (checkedPosition[0] != null && Intent.ACTION_CREATE_SHORTCUT.equals( + getIntent().getAction())) { + getBitmapFromUrl(listItems.get(checkedPosition[0])); + } + }); + + } + + public List<Feed> getFeedItems(List<NavDrawerData.DrawerItem> items, List<Feed> result) { + for (NavDrawerData.DrawerItem item : items) { + if (item.type == NavDrawerData.DrawerItem.Type.TAG) { + getFeedItems(((NavDrawerData.TagDrawerItem) item).children, result); + } else { + Feed feed = ((NavDrawerData.FeedDrawerItem) item).feed; + if (!result.contains(feed)) { + result.add(feed); + } + } + } + return result; + } + + private void addShortcut(Feed feed, Bitmap bitmap) { + Intent intent = new Intent(this, MainActivity.class); + intent.setAction(Intent.ACTION_MAIN); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra(EXTRA_FEED_ID, feed.getId()); + String id = "subscription-" + feed.getId(); + IconCompat icon; + + if (bitmap != null) { + icon = IconCompat.createWithAdaptiveBitmap(bitmap); + } else { + icon = IconCompat.createWithResource(this, R.drawable.ic_folder_shortcut); + } + + ShortcutInfoCompat shortcut = new ShortcutInfoCompat.Builder(this, id) + .setShortLabel(feed.getTitle()) + .setLongLabel(feed.getFeedTitle()) + .setIntent(intent) + .setIcon(icon) + .build(); + + setResult(RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this, shortcut)); + finish(); + } + + private void getBitmapFromUrl(Feed feed) { + int iconSize = (int) (128 * getResources().getDisplayMetrics().density); + Glide.with(this) + .asBitmap() + .load(feed.getImageUrl()) + .apply(new RequestOptions().override(iconSize, iconSize)) + .listener(new RequestListener<Bitmap>() { + @Override + public boolean onLoadFailed(@Nullable GlideException e, Object model, + Target<Bitmap> target, boolean isFirstResource) { + addShortcut(feed, null); + return true; + } + + @Override + public boolean onResourceReady(Bitmap resource, Object model, + Target<Bitmap> target, DataSource dataSource, boolean isFirstResource) { + addShortcut(feed, resource); + return true; + } + }).submit(); + } + + private void loadSubscriptions() { + if (disposable != null) { + disposable.dispose(); + } + disposable = Observable.fromCallable( + () -> { + NavDrawerData data = DBReader.getNavDrawerData(); + return getFeedItems(data.items, new ArrayList<>()); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + result -> { + listItems = result; + ArrayList<String> titles = new ArrayList<>(); + for (Feed feed: result) { + titles.add(feed.getTitle()); + } + ArrayAdapter<String> adapter = new ArrayAdapter<>(this, + R.layout.simple_list_item_multiple_choice_on_start, titles); + viewBinding.list.setAdapter(adapter); + }, error -> Log.e(TAG, Log.getStackTraceString(error))); + } +}
\ No newline at end of file diff --git a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java index d436acf0d..4ff2a5775 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java @@ -36,11 +36,13 @@ import androidx.core.view.WindowCompat; import androidx.interpolator.view.animation.FastOutSlowInInterpolator; import com.bumptech.glide.Glide; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.ServiceEvent; +import de.danoeh.antennapod.event.playback.BufferUpdateEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerErrorEvent; +import de.danoeh.antennapod.event.playback.PlaybackServiceEvent; +import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.util.Converter; @@ -50,7 +52,6 @@ import de.danoeh.antennapod.core.util.ShareUtils; import de.danoeh.antennapod.core.util.StorageUtils; import de.danoeh.antennapod.core.util.TimeSpeedConverter; import de.danoeh.antennapod.core.util.gui.PictureInPictureUtil; -import de.danoeh.antennapod.core.util.playback.MediaPlayerError; import de.danoeh.antennapod.core.util.playback.PlaybackController; import de.danoeh.antennapod.databinding.VideoplayerActivityBinding; import de.danoeh.antennapod.dialog.PlaybackControlsDialog; @@ -60,6 +61,8 @@ import de.danoeh.antennapod.dialog.SleepTimerDialog; 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.playback.base.PlayerStatus; +import de.danoeh.antennapod.playback.cast.CastEnabledActivity; import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -193,40 +196,11 @@ public class VideoplayerActivity extends CastEnabledActivity implements SeekBar. } @Override - public void onBufferStart() { - viewBinding.progressBar.setVisibility(View.VISIBLE); - } - - @Override - public void onBufferEnd() { - viewBinding.progressBar.setVisibility(View.INVISIBLE); - } - - @Override - public void onBufferUpdate(float progress) { - viewBinding.sbPosition.setSecondaryProgress((int) (progress * viewBinding.sbPosition.getMax())); - } - - @Override - public void handleError(int code) { - final AlertDialog.Builder errorDialog = new AlertDialog.Builder(VideoplayerActivity.this); - errorDialog.setTitle(R.string.error_label); - errorDialog.setMessage(MediaPlayerError.getErrorString(VideoplayerActivity.this, code)); - errorDialog.setNeutralButton(android.R.string.ok, (dialog, which) -> finish()); - errorDialog.show(); - } - - @Override public void onReloadNotification(int code) { VideoplayerActivity.this.onReloadNotification(code); } @Override - public void onSleepTimerUpdate() { - supportInvalidateOptionsMenu(); - } - - @Override protected void updatePlayButtonShowsPlay(boolean showPlay) { viewBinding.playButton.setIsShowPlay(showPlay); } @@ -261,6 +235,26 @@ public class VideoplayerActivity extends CastEnabledActivity implements SeekBar. }; } + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void bufferUpdate(BufferUpdateEvent event) { + if (event.hasStarted()) { + viewBinding.progressBar.setVisibility(View.VISIBLE); + } else if (event.hasEnded()) { + viewBinding.progressBar.setVisibility(View.INVISIBLE); + } else { + viewBinding.sbPosition.setSecondaryProgress((int) (event.getProgress() * viewBinding.sbPosition.getMax())); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { + if (event.isCancelled() || event.wasJustEnabled()) { + supportInvalidateOptionsMenu(); + } + } + protected void loadMediaInfo() { Log.d(TAG, "loadMediaInfo()"); if (controller == null || controller.getMedia() == null) { @@ -544,12 +538,21 @@ public class VideoplayerActivity extends CastEnabledActivity implements SeekBar. } @Subscribe(threadMode = ThreadMode.MAIN) - public void onPlaybackServiceChanged(ServiceEvent event) { - if (event.action == ServiceEvent.Action.SERVICE_SHUT_DOWN) { + public void onPlaybackServiceChanged(PlaybackServiceEvent event) { + if (event.action == PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) { finish(); } } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onMediaPlayerError(PlayerErrorEvent event) { + final AlertDialog.Builder errorDialog = new AlertDialog.Builder(VideoplayerActivity.this); + errorDialog.setTitle(R.string.error_label); + errorDialog.setMessage(event.getMessage()); + errorDialog.setNeutralButton(android.R.string.ok, (dialog, which) -> finish()); + errorDialog.show(); + } + @Override public boolean onCreateOptionsMenu(Menu menu) { super.onCreateOptionsMenu(menu); diff --git a/app/src/main/java/de/danoeh/antennapod/activity/WidgetConfigActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/WidgetConfigActivity.java index 3020aba43..674071294 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/WidgetConfigActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/WidgetConfigActivity.java @@ -1,21 +1,14 @@ package de.danoeh.antennapod.activity; -import android.Manifest; -import android.app.WallpaperManager; import android.appwidget.AppWidgetManager; import android.content.Intent; import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.drawable.Drawable; -import android.os.Build; import android.os.Bundle; import android.view.View; import android.widget.CheckBox; -import android.widget.ImageView; import android.widget.SeekBar; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.ContextCompat; import de.danoeh.antennapod.R; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.receiver.PlayerWidget; @@ -51,7 +44,6 @@ public class WidgetConfigActivity extends AppCompatActivity { finish(); } - displayDeviceBackground(); opacityTextView = findViewById(R.id.widget_opacity_textView); opacitySeekBar = findViewById(R.id.widget_opacity_seekBar); widgetPreview = findViewById(R.id.widgetLayout); @@ -102,16 +94,6 @@ public class WidgetConfigActivity extends AppCompatActivity { widgetPreview.findViewById(R.id.butRew).setVisibility(ckRewind.isChecked() ? View.VISIBLE : View.GONE); } - private void displayDeviceBackground() { - int permission = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); - if (Build.VERSION.SDK_INT < 27 || permission == PackageManager.PERMISSION_GRANTED) { - final WallpaperManager wallpaperManager = WallpaperManager.getInstance(this); - final Drawable wallpaperDrawable = wallpaperManager.getDrawable(); - ImageView background = findViewById(R.id.widget_config_background); - background.setImageDrawable(wallpaperDrawable); - } - } - private void confirmCreateWidget(View v) { int backgroundColor = getColorWithAlpha(PlayerWidget.DEFAULT_COLOR, opacitySeekBar.getProgress()); diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java b/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java index 5d7593564..34726e248 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java @@ -3,6 +3,9 @@ package de.danoeh.antennapod.adapter; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.graphics.ColorUtils; +import androidx.palette.graphics.Palette; + import android.view.View; import android.widget.ImageView; import android.widget.TextView; @@ -19,6 +22,9 @@ import com.bumptech.glide.request.transition.Transition; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.glide.ApGlideSettings; +import de.danoeh.antennapod.core.glide.PaletteBitmap; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.ui.common.ThemeUtils; public class CoverLoader { private int resource = 0; @@ -77,7 +83,7 @@ public class CoverLoader { if (resource != 0) { Glide.with(activity).clear(coverTarget); imgvCover.setImageResource(resource); - CoverTarget.setPlaceholderVisibility(txtvPlaceholder, textAndImageCombined); + CoverTarget.setPlaceholderVisibility(txtvPlaceholder, textAndImageCombined, null); return; } @@ -86,12 +92,14 @@ public class CoverLoader { .fitCenter() .dontAnimate(); - RequestBuilder<Drawable> builder = Glide.with(activity) + RequestBuilder<PaletteBitmap> builder = Glide.with(activity) + .as(PaletteBitmap.class) .load(uri) .apply(options); if (fallbackUri != null && txtvPlaceholder != null && imgvCover != null) { builder = builder.error(Glide.with(activity) + .as(PaletteBitmap.class) .load(fallbackUri) .apply(options)); } @@ -99,7 +107,7 @@ public class CoverLoader { builder.into(coverTarget); } - static class CoverTarget extends CustomViewTarget<ImageView, Drawable> { + static class CoverTarget extends CustomViewTarget<ImageView, PaletteBitmap> { private final WeakReference<TextView> placeholder; private final WeakReference<ImageView> cover; private boolean textAndImageCombined; @@ -120,23 +128,38 @@ public class CoverLoader { } @Override - public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) { - setPlaceholderVisibility(placeholder.get(), textAndImageCombined); + public void onResourceReady(@NonNull PaletteBitmap resource, + @Nullable Transition<? super PaletteBitmap> transition) { ImageView ivCover = cover.get(); - ivCover.setImageDrawable(resource); + ivCover.setImageBitmap(resource.bitmap); + setPlaceholderVisibility(placeholder.get(), textAndImageCombined, resource.palette); } @Override protected void onResourceCleared(@Nullable Drawable placeholder) { ImageView ivCover = cover.get(); ivCover.setImageDrawable(placeholder); + setPlaceholderVisibility(this.placeholder.get(), textAndImageCombined, null); } - static void setPlaceholderVisibility(TextView placeholder, boolean textAndImageCombined) { + static void setPlaceholderVisibility(TextView placeholder, boolean textAndImageCombined, Palette palette) { + boolean showTitle = UserPreferences.shouldShowSubscriptionTitle(); if (placeholder != null) { - if (textAndImageCombined) { + if (textAndImageCombined || showTitle) { int bgColor = placeholder.getContext().getResources().getColor(R.color.feed_text_bg); - placeholder.setBackgroundColor(bgColor); + if (palette == null || !showTitle) { + placeholder.setBackgroundColor(bgColor); + placeholder.setTextColor(ThemeUtils.getColorFromAttr(placeholder.getContext(), + android.R.attr.textColorPrimary)); + return; + } + int dominantColor = palette.getDominantColor(bgColor); + int textColor = placeholder.getContext().getResources().getColor(R.color.white); + if (ColorUtils.calculateLuminance(dominantColor) > 0.5) { + textColor = placeholder.getContext().getResources().getColor(R.color.black); + } + placeholder.setTextColor(textColor); + placeholder.setBackgroundColor(dominantColor); } else { placeholder.setVisibility(View.INVISIBLE); } diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DataFolderAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/DataFolderAdapter.java index bcad1b5a4..862dc2fe2 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/DataFolderAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DataFolderAdapter.java @@ -9,7 +9,6 @@ import android.widget.ProgressBar; import android.widget.RadioButton; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; import androidx.core.util.Consumer; import androidx.recyclerview.widget.RecyclerView; import de.danoeh.antennapod.R; @@ -74,7 +73,7 @@ public class DataFolderAdapter extends RecyclerView.Adapter<DataFolderAdapter.Vi } private List<StoragePath> getStorageEntries(Context context) { - File[] mediaDirs = ContextCompat.getExternalFilesDirs(context, null); + File[] mediaDirs = context.getExternalFilesDirs(null); final List<StoragePath> entries = new ArrayList<>(mediaDirs.length); for (File dir : mediaDirs) { if (!isWritable(dir)) { diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java index 2ab96e84d..5ddb6407c 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/FeedItemlistDescriptionAdapter.java @@ -95,7 +95,7 @@ public class FeedItemlistDescriptionAdapter extends ArrayAdapter<FeedItem> { holder.preview.setVisibility(View.GONE); holder.description.setTag(Boolean.FALSE); } else { - holder.description.setMaxLines(2000); + holder.description.setMaxLines(30); holder.description.setTag(Boolean.TRUE); holder.preview.setVisibility(item.getMedia() != null ? View.VISIBLE : View.GONE); diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java index ff0311ab6..34eb48b6b 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/NavListAdapter.java @@ -194,10 +194,10 @@ public class NavListAdapter extends RecyclerView.Adapter<NavListAdapter.Holder> bindListItem(item, (FeedHolder) holder); if (item.type == NavDrawerData.DrawerItem.Type.FEED) { bindFeedView((NavDrawerData.FeedDrawerItem) item, (FeedHolder) holder); - holder.itemView.setOnCreateContextMenuListener(itemAccess); } else { - bindFolderView((NavDrawerData.FolderDrawerItem) item, (FeedHolder) holder); + bindTagView((NavDrawerData.TagDrawerItem) item, (FeedHolder) holder); } + holder.itemView.setOnCreateContextMenuListener(itemAccess); } if (viewType != VIEW_TYPE_SECTION_DIVIDER) { TypedValue typedValue = new TypedValue(); @@ -327,16 +327,16 @@ public class NavListAdapter extends RecyclerView.Adapter<NavListAdapter.Holder> } } - private void bindFolderView(NavDrawerData.FolderDrawerItem folder, FeedHolder holder) { + private void bindTagView(NavDrawerData.TagDrawerItem tag, FeedHolder holder) { Activity context = activity.get(); if (context == null) { return; } - if (folder.isOpen) { + if (tag.isOpen) { holder.count.setVisibility(View.GONE); } Glide.with(context).clear(holder.image); - holder.image.setImageResource(R.drawable.ic_folder); + holder.image.setImageResource(R.drawable.ic_tag); holder.failure.setVisibility(View.GONE); } diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/PlaybackStatisticsListAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/PlaybackStatisticsListAdapter.java index 5fec5f063..26674b2b2 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/PlaybackStatisticsListAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/PlaybackStatisticsListAdapter.java @@ -1,13 +1,12 @@ package de.danoeh.antennapod.adapter; -import android.content.Context; -import androidx.appcompat.app.AlertDialog; - +import androidx.fragment.app.Fragment; import de.danoeh.antennapod.R; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.StatisticsItem; import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.core.util.DateFormatter; +import de.danoeh.antennapod.fragment.FeedStatisticsDialogFragment; import de.danoeh.antennapod.view.PieChartView; import java.util.Date; @@ -18,10 +17,12 @@ import java.util.List; */ public class PlaybackStatisticsListAdapter extends StatisticsListAdapter { + private final Fragment fragment; boolean countAll = true; - public PlaybackStatisticsListAdapter(Context context) { - super(context); + public PlaybackStatisticsListAdapter(Fragment fragment) { + super(fragment.getContext()); + this.fragment = fragment; } public void setCountAll(boolean countAll) { @@ -60,16 +61,9 @@ public class PlaybackStatisticsListAdapter extends StatisticsListAdapter { holder.value.setText(Converter.shortLocalizedDuration(context, time)); holder.itemView.setOnClickListener(v -> { - AlertDialog.Builder dialog = new AlertDialog.Builder(context); - dialog.setTitle(statsItem.feed.getTitle()); - dialog.setMessage(context.getString(R.string.statistics_details_dialog, - countAll ? statsItem.episodesStartedIncludingMarked : statsItem.episodesStarted, - statsItem.episodes, Converter.shortLocalizedDuration(context, - countAll ? statsItem.timePlayedCountAll : statsItem.timePlayed), - Converter.shortLocalizedDuration(context, statsItem.time))); - dialog.setPositiveButton(android.R.string.ok, null); - dialog.show(); + FeedStatisticsDialogFragment yourDialogFragment = FeedStatisticsDialogFragment.newInstance( + statsItem.feed.getId(), statsItem.feed.getTitle()); + yourDialogFragment.show(fragment.getChildFragmentManager().beginTransaction(), "DialogFragment"); }); } - } diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/QueueRecyclerAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/QueueRecyclerAdapter.java index 383d670f1..42813c8d6 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/QueueRecyclerAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/QueueRecyclerAdapter.java @@ -7,7 +7,6 @@ import android.view.MenuInflater; import android.view.MotionEvent; import android.view.View; -import androidx.core.view.ViewCompat; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -53,7 +52,7 @@ public class QueueRecyclerAdapter extends EpisodeItemListAdapter { }); holder.coverHolder.setOnTouchListener((v1, event) -> { if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - boolean isLtr = ViewCompat.getLayoutDirection(holder.itemView) == ViewCompat.LAYOUT_DIRECTION_LTR; + boolean isLtr = holder.itemView.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR; float factor = isLtr ? 1 : -1; if (factor * event.getX() < factor * 0.5 * v1.getWidth()) { Log.d(TAG, "startDrag()"); diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsRecyclerAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsRecyclerAdapter.java index 73f67d016..21c5e1897 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsRecyclerAdapter.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsRecyclerAdapter.java @@ -4,6 +4,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.text.TextUtils; import android.view.ContextMenu; import android.view.LayoutInflater; import android.view.MenuInflater; @@ -13,12 +14,11 @@ import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.text.TextUtilsCompat; -import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.RecyclerView; @@ -31,6 +31,7 @@ import java.util.Locale; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.feed.LocalFeedUpdater; +import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.NavDrawerData; import de.danoeh.antennapod.fragment.FeedItemlistFragment; import de.danoeh.antennapod.fragment.SubscriptionFragment; @@ -42,9 +43,11 @@ import jp.shts.android.library.TriangleLabelView; */ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<SubscriptionsRecyclerAdapter.SubscriptionViewHolder> implements View.OnCreateContextMenuListener { + private static final int COVER_WITH_TITLE = 1; + private final WeakReference<MainActivity> mainActivityRef; private List<NavDrawerData.DrawerItem> listItems; - private Feed selectedFeed = null; + private NavDrawerData.DrawerItem selectedItem = null; int longPressedPosition = 0; // used to init actionMode public SubscriptionsRecyclerAdapter(MainActivity mainActivity) { @@ -58,14 +61,31 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription return listItems.get(position); } - public Feed getSelectedFeed() { - return selectedFeed; + public NavDrawerData.DrawerItem getSelectedItem() { + return selectedItem; } @NonNull @Override public SubscriptionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { View itemView = LayoutInflater.from(mainActivityRef.get()).inflate(R.layout.subscription_item, parent, false); + TextView feedTitle = itemView.findViewById(R.id.txtvTitle); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) feedTitle.getLayoutParams(); + int topAndBottomItemId = R.id.imgvCover; + int belowItemId = 0; + + if (viewType == COVER_WITH_TITLE) { + topAndBottomItemId = 0; + belowItemId = R.id.imgvCover; + feedTitle.setBackgroundColor(feedTitle.getContext().getResources().getColor(R.color.feed_text_bg)); + int padding = (int) convertDpToPixel(feedTitle.getContext(), 6); + feedTitle.setPadding(padding, padding, padding, padding); + } + params.addRule(RelativeLayout.BELOW, belowItemId); + params.addRule(RelativeLayout.ALIGN_TOP, topAndBottomItemId); + params.addRule(RelativeLayout.ALIGN_BOTTOM, topAndBottomItemId); + feedTitle.setLayoutParams(params); + feedTitle.setSingleLine(viewType == COVER_WITH_TITLE); return new SubscriptionViewHolder(itemView); } @@ -93,11 +113,9 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription holder.itemView.setOnLongClickListener(v -> { if (!inActionMode()) { if (isFeed) { - selectedFeed = ((NavDrawerData.FeedDrawerItem) getItem(holder.getBindingAdapterPosition())).feed; longPressedPosition = holder.getBindingAdapterPosition(); - } else { - selectedFeed = null; } + selectedItem = (NavDrawerData.DrawerItem) getItem(holder.getBindingAdapterPosition()); } return false; }); @@ -131,12 +149,17 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) { - if (selectedFeed != null && !inActionMode()) { - MenuInflater inflater = mainActivityRef.get().getMenuInflater(); + if (inActionMode() || selectedItem == null) { + return; + } + MenuInflater inflater = mainActivityRef.get().getMenuInflater(); + if (selectedItem.type == NavDrawerData.DrawerItem.Type.FEED) { inflater.inflate(R.menu.nav_feed_context, menu); - menu.setHeaderTitle(selectedFeed.getTitle()); menu.findItem(R.id.multi_select).setVisible(true); + } else { + inflater.inflate(R.menu.nav_folder_context, menu); } + menu.setHeaderTitle(selectedItem.getTitle()); } public boolean onContextItemSelected(MenuItem item) { @@ -173,6 +196,11 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription } } + @Override + public int getItemViewType(int position) { + return UserPreferences.shouldShowSubscriptionTitle() ? COVER_WITH_TITLE : 0; + } + public class SubscriptionViewHolder extends RecyclerView.ViewHolder { private final TextView feedTitle; private final ImageView imageView; @@ -196,8 +224,7 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription feedTitle.setText(drawerItem.getTitle()); imageView.setContentDescription(drawerItem.getTitle()); feedTitle.setVisibility(View.VISIBLE); - if (TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) - == ViewCompat.LAYOUT_DIRECTION_RTL) { + if (TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == View.LAYOUT_DIRECTION_RTL) { count.setCorner(TriangleLabelView.Corner.TOP_LEFT); } @@ -219,7 +246,7 @@ public class SubscriptionsRecyclerAdapter extends SelectableAdapter<Subscription .load(); } else { new CoverLoader(mainActivityRef.get()) - .withResource(R.drawable.ic_folder) + .withResource(R.drawable.ic_tag) .withPlaceholderView(feedTitle, true) .withCoverView(imageView) .load(); diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/CancelDownloadActionButton.java b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/CancelDownloadActionButton.java index dedf8e5e6..a2b0e98c3 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/CancelDownloadActionButton.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/CancelDownloadActionButton.java @@ -34,7 +34,7 @@ public class CancelDownloadActionButton extends ItemActionButton { FeedMedia media = item.getMedia(); DownloadRequester.getInstance().cancelDownload(context, media); if (UserPreferences.isEnableAutodownload()) { - item.setAutoDownload(false); + item.disableAutoDownload(); DBWriter.setFeedItem(item); } } diff --git a/app/src/main/java/de/danoeh/antennapod/config/ClientConfigurator.java b/app/src/main/java/de/danoeh/antennapod/config/ClientConfigurator.java index a45eb5199..1f4f657b1 100644 --- a/app/src/main/java/de/danoeh/antennapod/config/ClientConfigurator.java +++ b/app/src/main/java/de/danoeh/antennapod/config/ClientConfigurator.java @@ -15,6 +15,5 @@ class ClientConfigurator { ClientConfig.USER_AGENT = "AntennaPod/" + BuildConfig.VERSION_NAME; ClientConfig.applicationCallbacks = new ApplicationCallbacksImpl(); ClientConfig.downloadServiceCallbacks = new DownloadServiceCallbacksImpl(); - ClientConfig.castCallbacks = new CastCallbackImpl(); } } diff --git a/app/src/main/java/de/danoeh/antennapod/config/DownloadServiceCallbacksImpl.java b/app/src/main/java/de/danoeh/antennapod/config/DownloadServiceCallbacksImpl.java index 938bb5931..590b7c897 100644 --- a/app/src/main/java/de/danoeh/antennapod/config/DownloadServiceCallbacksImpl.java +++ b/app/src/main/java/de/danoeh/antennapod/config/DownloadServiceCallbacksImpl.java @@ -3,6 +3,7 @@ package de.danoeh.antennapod.config; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Bundle; import de.danoeh.antennapod.R; @@ -24,7 +25,8 @@ public class DownloadServiceCallbacksImpl implements DownloadServiceCallbacks { args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_LOG); intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args); return PendingIntent.getActivity(context, - R.id.pending_intent_download_service_notification, intent, PendingIntent.FLAG_UPDATE_CURRENT); + R.id.pending_intent_download_service_notification, intent, + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } @Override @@ -33,7 +35,8 @@ public class DownloadServiceCallbacksImpl implements DownloadServiceCallbacks { activityIntent.setAction("request" + request.getFeedfileId()); activityIntent.putExtra(DownloadAuthenticationActivity.ARG_DOWNLOAD_REQUEST, request); return PendingIntent.getActivity(context.getApplicationContext(), - R.id.pending_intent_download_service_auth, activityIntent, PendingIntent.FLAG_ONE_SHOT); + R.id.pending_intent_download_service_auth, activityIntent, + PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } @Override @@ -43,15 +46,15 @@ public class DownloadServiceCallbacksImpl implements DownloadServiceCallbacks { Bundle args = new Bundle(); args.putInt(DownloadsFragment.ARG_SELECTED_TAB, DownloadsFragment.POS_LOG); intent.putExtra(MainActivity.EXTRA_FRAGMENT_ARGS, args); - return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, - intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, intent, + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } @Override public PendingIntent getAutoDownloadReportNotificationContentIntent(Context context) { Intent intent = new Intent(context, MainActivity.class); intent.putExtra(MainActivity.EXTRA_FRAGMENT_TAG, QueueFragment.TAG); - return PendingIntent.getActivity(context, R.id.pending_intent_download_service_autodownload_report, - intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getActivity(context, R.id.pending_intent_download_service_autodownload_report, intent, + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } } diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/EpisodeFilterDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/EpisodeFilterDialog.java index ee19a0339..595f37e40 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/EpisodeFilterDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/EpisodeFilterDialog.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.dialog; import android.content.Context; import android.view.View; +import android.widget.CheckBox; import android.widget.EditText; import android.widget.RadioButton; @@ -14,7 +15,6 @@ import de.danoeh.antennapod.model.feed.FeedFilter; * Displays a dialog with a text box for filtering episodes and two radio buttons for exclusion/inclusion */ public abstract class EpisodeFilterDialog extends AlertDialog.Builder { - private final FeedFilter initialFilter; public EpisodeFilterDialog(Context context, FeedFilter filter) { @@ -26,8 +26,10 @@ public abstract class EpisodeFilterDialog extends AlertDialog.Builder { setView(rootView); final EditText etxtEpisodeFilterText = rootView.findViewById(R.id.etxtEpisodeFilterText); + final EditText etxtEpisodeFilterDurationText = rootView.findViewById(R.id.etxtEpisodeFilterDurationText); final RadioButton radioInclude = rootView.findViewById(R.id.radio_filter_include); final RadioButton radioExclude = rootView.findViewById(R.id.radio_filter_exclude); + final CheckBox checkboxDuration = rootView.findViewById(R.id.checkbox_filter_duration); if (initialFilter.includeOnly()) { radioInclude.setChecked(true); @@ -40,18 +42,31 @@ public abstract class EpisodeFilterDialog extends AlertDialog.Builder { radioInclude.setChecked(false); etxtEpisodeFilterText.setText(""); } + if (initialFilter.hasMinimalDurationFilter()) { + checkboxDuration.setChecked(true); + // Store minimal duration in seconds, show in minutes + etxtEpisodeFilterDurationText.setText(String.valueOf(initialFilter.getMinimalDurationFilter() / 60)); + } setNegativeButton(R.string.cancel_label, null); setPositiveButton(R.string.confirm_label, (dialog, which) -> { String includeString = ""; String excludeString = ""; + int minimalDuration = -1; if (radioInclude.isChecked()) { includeString = etxtEpisodeFilterText.getText().toString(); } else { excludeString = etxtEpisodeFilterText.getText().toString(); } - - onConfirmed(new FeedFilter(includeString, excludeString)); + if (checkboxDuration.isChecked()) { + try { + // Store minimal duration in seconds + minimalDuration = Integer.parseInt(etxtEpisodeFilterDurationText.getText().toString()) * 60; + } catch (NumberFormatException e) { + // Do not change anything on error + } + } + onConfirmed(new FeedFilter(includeString, excludeString, minimalDuration)); } ); } diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/FeedSortDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/FeedSortDialog.java index 96d1b9b67..b89d05f88 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/FeedSortDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/FeedSortDialog.java @@ -10,7 +10,7 @@ import java.util.Arrays; import java.util.List; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; public class FeedSortDialog { diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/PlaybackControlsDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/PlaybackControlsDialog.java index 3186cbe2e..5cc1f99c6 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/PlaybackControlsDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/PlaybackControlsDialog.java @@ -12,9 +12,13 @@ import android.widget.Button; import android.widget.CheckBox; import android.widget.TextView; import de.danoeh.antennapod.R; +import de.danoeh.antennapod.event.playback.SpeedChangedEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.util.playback.PlaybackController; import de.danoeh.antennapod.view.PlaybackSpeedSeekBar; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import java.util.List; import java.util.Locale; @@ -22,6 +26,8 @@ import java.util.Locale; public class PlaybackControlsDialog extends DialogFragment { private PlaybackController controller; private AlertDialog dialog; + private PlaybackSpeedSeekBar speedSeekBar; + private TextView txtvPlaybackSpeed; public static PlaybackControlsDialog newInstance() { Bundle arguments = new Bundle(); @@ -42,10 +48,12 @@ public class PlaybackControlsDialog extends DialogFragment { public void loadMediaInfo() { setupUi(); setupAudioTracks(); + updateSpeed(new SpeedChangedEvent(getCurrentPlaybackSpeedMultiplier())); } }; controller.init(); setupUi(); + EventBus.getDefault().register(this); } @Override @@ -53,6 +61,7 @@ public class PlaybackControlsDialog extends DialogFragment { super.onStop(); controller.release(); controller = null; + EventBus.getDefault().unregister(this); } @NonNull @@ -66,12 +75,14 @@ public class PlaybackControlsDialog extends DialogFragment { } private void setupUi() { - final TextView txtvPlaybackSpeed = dialog.findViewById(R.id.txtvPlaybackSpeed); - - PlaybackSpeedSeekBar speedSeekBar = dialog.findViewById(R.id.speed_seek_bar); - speedSeekBar.setController(controller); - speedSeekBar.setProgressChangedListener(speed - -> txtvPlaybackSpeed.setText(String.format(Locale.getDefault(), "%.2fx", speed))); + txtvPlaybackSpeed = dialog.findViewById(R.id.txtvPlaybackSpeed); + speedSeekBar = dialog.findViewById(R.id.speed_seek_bar); + speedSeekBar.setProgressChangedListener(speed -> { + if (controller != null) { + controller.setPlaybackSpeed(speed); + } + }); + updateSpeed(new SpeedChangedEvent(controller.getCurrentPlaybackSpeedMultiplier())); final CheckBox stereoToMono = dialog.findViewById(R.id.stereo_to_mono); stereoToMono.setChecked(UserPreferences.stereoToMono()); @@ -100,6 +111,12 @@ public class PlaybackControlsDialog extends DialogFragment { }); } + @Subscribe(threadMode = ThreadMode.MAIN) + public void updateSpeed(SpeedChangedEvent event) { + txtvPlaybackSpeed.setText(String.format(Locale.getDefault(), "%.2fx", event.getNewSpeed())); + speedSeekBar.updateSpeed(event.getNewSpeed()); + } + private void setupAudioTracks() { List<String> audioTracks = controller.getAudioTracks(); int selectedAudioTrack = controller.getSelectedAudioTrack(); diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/ProxyDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/ProxyDialog.java index 13258b4ec..ad2ed3499 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/ProxyDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/ProxyDialog.java @@ -67,6 +67,7 @@ public class ProxyDialog { .setView(content) .setNegativeButton(R.string.cancel_label, null) .setPositiveButton(R.string.proxy_test_label, null) + .setNeutralButton(R.string.reset, null) .show(); // To prevent cancelling the dialog on button click dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener((view) -> { @@ -75,36 +76,19 @@ public class ProxyDialog { test(); return; } - String type = (String) spType.getSelectedItem(); - ProxyConfig proxy; - if (Proxy.Type.valueOf(type) == Proxy.Type.DIRECT) { - proxy = ProxyConfig.direct(); - } else { - String host = etHost.getText().toString(); - String port = etPort.getText().toString(); - String username = etUsername.getText().toString(); - if (TextUtils.isEmpty(username)) { - username = null; - } - String password = etPassword.getText().toString(); - if (TextUtils.isEmpty(password)) { - password = null; - } - int portValue = 0; - if (!TextUtils.isEmpty(port)) { - portValue = Integer.parseInt(port); - } - if (Proxy.Type.valueOf(type) == Proxy.Type.SOCKS) { - proxy = ProxyConfig.socks(host, portValue, username, password); - } else { - proxy = ProxyConfig.http(host, portValue, username, password); - } - } - UserPreferences.setProxyConfig(proxy); + setProxyConfig(); AntennapodHttpClient.reinit(); dialog.dismiss(); }); + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener((view) -> { + etHost.getText().clear(); + etPort.getText().clear(); + etUsername.getText().clear(); + etPassword.getText().clear(); + setProxyConfig(); + }); + List<String> types = new ArrayList<>(); types.add(Proxy.Type.DIRECT.name()); types.add(Proxy.Type.HTTP.name()); @@ -144,6 +128,11 @@ public class ProxyDialog { spType.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + if (position == 0) { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setVisibility(View.GONE); + } else { + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setVisibility(View.VISIBLE); + } enableSettings(position > 0); setTestRequired(position > 0); } @@ -158,6 +147,35 @@ public class ProxyDialog { return dialog; } + private void setProxyConfig() { + String type = (String) spType.getSelectedItem(); + ProxyConfig proxy; + if (Proxy.Type.valueOf(type) == Proxy.Type.DIRECT) { + proxy = ProxyConfig.direct(); + } else { + String host = etHost.getText().toString(); + String port = etPort.getText().toString(); + String username = etUsername.getText().toString(); + if (TextUtils.isEmpty(username)) { + username = null; + } + String password = etPassword.getText().toString(); + if (TextUtils.isEmpty(password)) { + password = null; + } + int portValue = 0; + if (!TextUtils.isEmpty(port)) { + portValue = Integer.parseInt(port); + } + if (Proxy.Type.valueOf(type) == Proxy.Type.SOCKS) { + proxy = ProxyConfig.socks(host, portValue, username, password); + } else { + proxy = ProxyConfig.http(host, portValue, username, password); + } + } + UserPreferences.setProxyConfig(proxy); + } + private final TextWatcher requireTestOnChange = new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/RemoveFeedDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/RemoveFeedDialog.java index 9fcf8be69..23c032248 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/RemoveFeedDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/RemoveFeedDialog.java @@ -19,18 +19,18 @@ import io.reactivex.schedulers.Schedulers; public class RemoveFeedDialog { private static final String TAG = "RemoveFeedDialog"; - public static void show(Context context, Feed feed, Runnable onSuccess) { + public static void show(Context context, Feed feed) { List<Feed> feeds = Collections.singletonList(feed); String message = getMessageId(context, feeds); - showDialog(context, feeds, message, onSuccess); + showDialog(context, feeds, message); } - public static void show(Context context, List<Feed> feeds, Runnable onSuccess) { + public static void show(Context context, List<Feed> feeds) { String message = getMessageId(context, feeds); - showDialog(context, feeds, message, onSuccess); + showDialog(context, feeds, message); } - private static void showDialog(Context context, List<Feed> feeds, String message, Runnable onSuccess) { + private static void showDialog(Context context, List<Feed> feeds, String message) { ConfirmationDialog dialog = new ConfirmationDialog(context, R.string.remove_feed_label, message) { @Override public void onConfirmButtonPressed(DialogInterface clickedDialog) { @@ -42,20 +42,16 @@ public class RemoveFeedDialog { progressDialog.setCancelable(false); progressDialog.show(); - Completable.fromCallable(() -> { + Completable.fromAction(() -> { for (Feed feed : feeds) { DBWriter.deleteFeed(context, feed.getId()).get(); } - return null; }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( () -> { Log.d(TAG, "Feed(s) deleted"); - if (onSuccess != null) { - onSuccess.run(); - } progressDialog.dismiss(); }, error -> { Log.e(TAG, Log.getStackTraceString(error)); diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/RenameFeedDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/RenameFeedDialog.java deleted file mode 100644 index 42a854cd8..000000000 --- a/app/src/main/java/de/danoeh/antennapod/dialog/RenameFeedDialog.java +++ /dev/null @@ -1,50 +0,0 @@ -package de.danoeh.antennapod.dialog; - -import android.app.Activity; - -import java.lang.ref.WeakReference; - -import android.view.View; -import androidx.appcompat.app.AlertDialog; -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.databinding.EditTextDialogBinding; - -public class RenameFeedDialog { - - private final WeakReference<Activity> activityRef; - private final Feed feed; - - public RenameFeedDialog(Activity activity, Feed feed) { - this.activityRef = new WeakReference<>(activity); - this.feed = feed; - } - - public void show() { - Activity activity = activityRef.get(); - if(activity == null) { - return; - } - - View content = View.inflate(activity, R.layout.edit_text_dialog, null); - EditTextDialogBinding alertViewBinding = EditTextDialogBinding.bind(content); - - alertViewBinding.urlEditText.setText(feed.getTitle()); - AlertDialog dialog = new AlertDialog.Builder(activity) - .setView(content) - .setTitle(de.danoeh.antennapod.core.R.string.rename_feed_label) - .setPositiveButton(android.R.string.ok, (d, input) -> { - feed.setCustomTitle(alertViewBinding.urlEditText.getText().toString()); - DBWriter.setFeedCustomTitle(feed); - }) - .setNeutralButton(de.danoeh.antennapod.core.R.string.reset, null) - .setNegativeButton(de.danoeh.antennapod.core.R.string.cancel_label, null) - .show(); - - // To prevent cancelling the dialog on button click - dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener( - (view) -> alertViewBinding.urlEditText.setText(feed.getFeedTitle())); - } - -} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/RenameItemDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/RenameItemDialog.java new file mode 100644 index 000000000..2f9516e0c --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/dialog/RenameItemDialog.java @@ -0,0 +1,81 @@ +package de.danoeh.antennapod.dialog; + +import android.app.Activity; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; + +import android.view.View; +import androidx.appcompat.app.AlertDialog; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.core.storage.NavDrawerData; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.databinding.EditTextDialogBinding; +import de.danoeh.antennapod.model.feed.FeedPreferences; + +public class RenameItemDialog { + + private final WeakReference<Activity> activityRef; + private Feed feed = null; + private NavDrawerData.DrawerItem drawerItem = null; + + public RenameItemDialog(Activity activity, Feed feed) { + this.activityRef = new WeakReference<>(activity); + this.feed = feed; + } + + public RenameItemDialog(Activity activity, NavDrawerData.DrawerItem drawerItem) { + this.activityRef = new WeakReference<>(activity); + this.drawerItem = drawerItem; + } + + public void show() { + Activity activity = activityRef.get(); + if (activity == null) { + return; + } + + View content = View.inflate(activity, R.layout.edit_text_dialog, null); + EditTextDialogBinding alertViewBinding = EditTextDialogBinding.bind(content); + String title = feed != null ? feed.getTitle() : drawerItem.getTitle(); + + alertViewBinding.urlEditText.setText(title); + AlertDialog dialog = new AlertDialog.Builder(activity) + .setView(content) + .setTitle(feed != null ? R.string.rename_feed_label : R.string.rename_tag_label) + .setPositiveButton(android.R.string.ok, (d, input) -> { + String newTitle = alertViewBinding.urlEditText.getText().toString(); + if (feed != null) { + feed.setCustomTitle(newTitle); + DBWriter.setFeedCustomTitle(feed); + } else { + renameTag(newTitle); + } + }) + .setNeutralButton(de.danoeh.antennapod.core.R.string.reset, null) + .setNegativeButton(de.danoeh.antennapod.core.R.string.cancel_label, null) + .show(); + + // To prevent cancelling the dialog on button click + dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener( + (view) -> alertViewBinding.urlEditText.setText(title)); + } + + private void renameTag(String title) { + if (NavDrawerData.DrawerItem.Type.TAG == drawerItem.type) { + List<FeedPreferences> feedPreferences = new ArrayList<>(); + for (NavDrawerData.DrawerItem item : ((NavDrawerData.TagDrawerItem) drawerItem).children) { + feedPreferences.add(((NavDrawerData.FeedDrawerItem) item).feed.getPreferences()); + } + + for (FeedPreferences preferences : feedPreferences) { + preferences.getTags().remove(drawerItem.getTitle()); + preferences.getTags().add(title); + DBWriter.setFeedPreferences(preferences); + } + } + } + +} diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/SleepTimerDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/SleepTimerDialog.java index 691bd65e8..8cd34b5f8 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/SleepTimerDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/SleepTimerDialog.java @@ -18,20 +18,17 @@ import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import com.google.android.material.snackbar.Snackbar; import de.danoeh.antennapod.R; +import de.danoeh.antennapod.event.playback.SleepTimerUpdatedEvent; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.core.util.playback.PlaybackController; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; - -import java.util.concurrent.TimeUnit; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; public class SleepTimerDialog extends DialogFragment { private PlaybackController controller; - private Disposable timeUpdater; - private EditText etxtTime; private Spinner spTimeUnit; private LinearLayout timeSetup; @@ -47,19 +44,11 @@ public class SleepTimerDialog extends DialogFragment { super.onStart(); controller = new PlaybackController(getActivity()) { @Override - public void onSleepTimerUpdate() { - updateTime(); - } - - @Override public void loadMediaInfo() { - updateTime(); } }; controller.init(); - timeUpdater = Observable.interval(1, TimeUnit.SECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(tick -> updateTime()); + EventBus.getDefault().register(this); } @Override @@ -68,9 +57,7 @@ public class SleepTimerDialog extends DialogFragment { if (controller != null) { controller.release(); } - if (timeUpdater != null) { - timeUpdater.dispose(); - } + EventBus.getDefault().unregister(this); } @NonNull @@ -86,6 +73,7 @@ public class SleepTimerDialog extends DialogFragment { spTimeUnit = content.findViewById(R.id.spTimeUnit); timeSetup = content.findViewById(R.id.timeSetup); timeDisplay = content.findViewById(R.id.timeDisplay); + timeDisplay.setVisibility(View.GONE); time = content.findViewById(R.id.time); Button extendSleepFiveMinutesButton = content.findViewById(R.id.extendSleepFiveMinutesButton); extendSleepFiveMinutesButton.setText(getString(R.string.extend_sleep_timer_label, 5)); @@ -170,13 +158,12 @@ public class SleepTimerDialog extends DialogFragment { return builder.create(); } - private void updateTime() { - if (controller == null) { - return; - } - timeSetup.setVisibility(controller.sleepTimerActive() ? View.GONE : View.VISIBLE); - timeDisplay.setVisibility(controller.sleepTimerActive() ? View.VISIBLE : View.GONE); - time.setText(Converter.getDurationStringLong((int) controller.getSleepTimerTimeLeft())); + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void timerUpdated(SleepTimerUpdatedEvent event) { + timeDisplay.setVisibility(event.isOver() || event.isCancelled() ? View.GONE : View.VISIBLE); + timeSetup.setVisibility(event.isOver() || event.isCancelled() ? View.VISIBLE : View.GONE); + time.setText(Converter.getDurationStringLong((int) event.getTimeLeft())); } private void closeKeyboard(View content) { diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/SubscriptionsFilterDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/SubscriptionsFilterDialog.java index 29172bb5e..9e524188f 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/SubscriptionsFilterDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/SubscriptionsFilterDialog.java @@ -16,7 +16,7 @@ import java.util.HashSet; import java.util.Set; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.feed.SubscriptionsFilter; import de.danoeh.antennapod.core.feed.SubscriptionsFilterGroup; import de.danoeh.antennapod.core.preferences.UserPreferences; diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/TagSettingsDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/TagSettingsDialog.java index 8ef01590f..8f5f1b802 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/TagSettingsDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/TagSettingsDialog.java @@ -27,7 +27,9 @@ import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class TagSettingsDialog extends DialogFragment { public static final String TAG = "TagSettingsDialog"; @@ -36,10 +38,10 @@ public class TagSettingsDialog extends DialogFragment { private EditTagsDialogBinding viewBinding; private TagSelectionAdapter adapter; - public static TagSettingsDialog newInstance(FeedPreferences preferences) { + public static TagSettingsDialog newInstance(List<FeedPreferences> preferencesList) { TagSettingsDialog fragment = new TagSettingsDialog(); Bundle args = new Bundle(); - args.putSerializable(ARG_FEED_PREFERENCES, preferences); + args.putSerializable(ARG_FEED_PREFERENCES, new ArrayList<>(preferencesList)); fragment.setArguments(args); return fragment; } @@ -47,8 +49,14 @@ public class TagSettingsDialog extends DialogFragment { @NonNull @Override public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { - FeedPreferences preferences = (FeedPreferences) getArguments().getSerializable(ARG_FEED_PREFERENCES); - displayedTags = new ArrayList<>(preferences.getTags()); + ArrayList<FeedPreferences> feedPreferencesList = + (ArrayList<FeedPreferences>) getArguments().getSerializable(ARG_FEED_PREFERENCES); + Set<String> commonTags = new HashSet<>(feedPreferencesList.get(0).getTags()); + + for (FeedPreferences preference : feedPreferencesList) { + commonTags.retainAll(preference.getTags()); + } + displayedTags = new ArrayList<>(commonTags); displayedTags.remove(FeedPreferences.TAG_ROOT); viewBinding = EditTagsDialogBinding.inflate(getLayoutInflater()); @@ -57,7 +65,7 @@ public class TagSettingsDialog extends DialogFragment { adapter = new TagSelectionAdapter(); adapter.setHasStableIds(true); viewBinding.tagsRecycler.setAdapter(adapter); - viewBinding.rootFolderCheckbox.setChecked(preferences.getTags().contains(FeedPreferences.TAG_ROOT)); + viewBinding.rootFolderCheckbox.setChecked(commonTags.contains(FeedPreferences.TAG_ROOT)); viewBinding.newTagButton.setOnClickListener(v -> addTag(viewBinding.newTagEditText.getText().toString().trim())); @@ -73,17 +81,16 @@ public class TagSettingsDialog extends DialogFragment { } }); + if (feedPreferencesList.size() > 1) { + viewBinding.commonTagsInfo.setVisibility(View.VISIBLE); + } + AlertDialog.Builder dialog = new AlertDialog.Builder(getContext()); dialog.setView(viewBinding.getRoot()); - dialog.setTitle(R.string.feed_folders_label); + dialog.setTitle(R.string.feed_tags_label); dialog.setPositiveButton(android.R.string.ok, (d, input) -> { addTag(viewBinding.newTagEditText.getText().toString().trim()); - preferences.getTags().clear(); - preferences.getTags().addAll(displayedTags); - if (viewBinding.rootFolderCheckbox.isChecked()) { - preferences.getTags().add(FeedPreferences.TAG_ROOT); - } - DBWriter.setFeedPreferences(preferences); + updatePreferencesTags(feedPreferencesList, commonTags); }); dialog.setNegativeButton(R.string.cancel_label, null); return dialog.create(); @@ -96,7 +103,7 @@ public class TagSettingsDialog extends DialogFragment { List<NavDrawerData.DrawerItem> items = data.items; List<String> folders = new ArrayList<String>(); for (NavDrawerData.DrawerItem item : items) { - if (item.type == NavDrawerData.DrawerItem.Type.FOLDER) { + if (item.type == NavDrawerData.DrawerItem.Type.TAG) { folders.add(item.getTitle()); } } @@ -123,6 +130,17 @@ public class TagSettingsDialog extends DialogFragment { adapter.notifyDataSetChanged(); } + private void updatePreferencesTags(List<FeedPreferences> feedPreferencesList, Set<String> commonTags) { + if (viewBinding.rootFolderCheckbox.isChecked()) { + displayedTags.add(FeedPreferences.TAG_ROOT); + } + for (FeedPreferences preferences : feedPreferencesList) { + preferences.getTags().removeAll(commonTags); + preferences.getTags().addAll(displayedTags); + DBWriter.setFeedPreferences(preferences); + } + } + public class TagSelectionAdapter extends RecyclerView.Adapter<TagSelectionAdapter.ViewHolder> { @Override diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java index def2e56a7..2bce73b79 100644 --- a/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java +++ b/app/src/main/java/de/danoeh/antennapod/dialog/VariableSpeedDialog.java @@ -1,6 +1,5 @@ package de.danoeh.antennapod.dialog; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -15,10 +14,14 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.android.material.chip.Chip; import com.google.android.material.snackbar.Snackbar; import de.danoeh.antennapod.R; +import de.danoeh.antennapod.event.playback.SpeedChangedEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.util.playback.PlaybackController; import de.danoeh.antennapod.view.ItemOffsetDecoration; import de.danoeh.antennapod.view.PlaybackSpeedSeekBar; +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; @@ -47,22 +50,12 @@ public class VariableSpeedDialog extends BottomSheetDialogFragment { super.onStart(); controller = new PlaybackController(getActivity()) { @Override - public void onPlaybackSpeedChange() { - updateSpeed(); - } - - @Override public void loadMediaInfo() { - updateSpeed(); + updateSpeed(new SpeedChangedEvent(controller.getCurrentPlaybackSpeedMultiplier())); } }; controller.init(); - speedSeekBar.setController(controller); - } - - private void updateSpeed() { - speedSeekBar.updateSpeed(); - addCurrentSpeedChip.setText(speedFormat.format(controller.getCurrentPlaybackSpeedMultiplier())); + EventBus.getDefault().register(this); } @Override @@ -70,6 +63,13 @@ public class VariableSpeedDialog extends BottomSheetDialogFragment { super.onStop(); controller.release(); controller = null; + EventBus.getDefault().unregister(this); + } + + @Subscribe(threadMode = ThreadMode.MAIN) + public void updateSpeed(SpeedChangedEvent event) { + speedSeekBar.updateSpeed(event.getNewSpeed()); + addCurrentSpeedChip.setText(speedFormat.format(event.getNewSpeed())); } @Nullable @@ -78,6 +78,11 @@ public class VariableSpeedDialog extends BottomSheetDialogFragment { @Nullable Bundle savedInstanceState) { View root = View.inflate(getContext(), R.layout.speed_select_dialog, null); speedSeekBar = root.findViewById(R.id.speed_seek_bar); + speedSeekBar.setProgressChangedListener(multiplier -> { + if (controller != null) { + controller.setPlaybackSpeed(multiplier); + } + }); RecyclerView selectedSpeedsGrid = root.findViewById(R.id.selected_speeds_grid); selectedSpeedsGrid.setLayoutManager(new GridLayoutManager(getContext(), 3)); selectedSpeedsGrid.addItemDecoration(new ItemOffsetDecoration(getContext(), 4)); @@ -112,9 +117,7 @@ public class VariableSpeedDialog extends BottomSheetDialogFragment { @NonNull public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { Chip chip = new Chip(getContext()); - if (Build.VERSION.SDK_INT >= 17) { - chip.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); - } + chip.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); return new ViewHolder(chip); } diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java index f97c1c7ab..340783208 100644 --- a/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java +++ b/app/src/main/java/de/danoeh/antennapod/discovery/GpodnetPodcastSearcher.java @@ -1,6 +1,6 @@ package de.danoeh.antennapod.discovery; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetServiceException; @@ -18,8 +18,8 @@ public class GpodnetPodcastSearcher implements PodcastSearcher { return Single.create((SingleOnSubscribe<List<PodcastSearchResult>>) subscriber -> { try { GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(), - GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(), - GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); List<GpodnetPodcast> gpodnetPodcasts = service.searchPodcasts(query, 0); List<PodcastSearchResult> results = new ArrayList<>(); for (GpodnetPodcast podcast : gpodnetPodcasts) { diff --git a/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java index 6e894176f..5f3dd5f61 100644 --- a/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java +++ b/app/src/main/java/de/danoeh/antennapod/discovery/ItunesPodcastSearcher.java @@ -17,9 +17,12 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class ItunesPodcastSearcher implements PodcastSearcher { private static final String ITUNES_API_URL = "https://itunes.apple.com/search?media=podcast&term=%s"; + private static final String PATTERN_BY_ID = ".*/podcasts\\.apple\\.com/.*/podcast/.*/id(\\d+).*"; public ItunesPodcastSearcher() { } @@ -70,9 +73,12 @@ public class ItunesPodcastSearcher implements PodcastSearcher { @Override public Single<String> lookupUrl(String url) { + Pattern pattern = Pattern.compile(PATTERN_BY_ID); + Matcher matcher = pattern.matcher(url); + final String lookupUrl = matcher.find() ? ("https://itunes.apple.com/lookup?id=" + matcher.group(1)) : url; return Single.create(emitter -> { OkHttpClient client = AntennapodHttpClient.getHttpClient(); - Request.Builder httpReq = new Request.Builder().url(url); + Request.Builder httpReq = new Request.Builder().url(lookupUrl); try { Response response = client.newCall(httpReq.build()).execute(); if (response.isSuccessful()) { @@ -92,7 +98,7 @@ public class ItunesPodcastSearcher implements PodcastSearcher { @Override public boolean urlNeedsLookup(String url) { - return url.contains("itunes.apple.com"); + return url.contains("itunes.apple.com") || url.matches(PATTERN_BY_ID); } @Override diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java index 64e7f161e..8c01a4563 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java @@ -1,6 +1,5 @@ package de.danoeh.antennapod.fragment; -import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ClipboardManager; import android.content.Context; @@ -12,9 +11,14 @@ import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.activity.result.contract.ActivityResultContracts.GetContent; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.documentfile.provider.DocumentFile; @@ -48,14 +52,17 @@ import java.util.Collections; public class AddFeedFragment extends Fragment { public static final String TAG = "AddFeedFragment"; - private static final int REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH = 1; - private static final int REQUEST_CODE_ADD_LOCAL_FOLDER = 2; private static final String KEY_UP_ARROW = "up_arrow"; private AddfeedBinding viewBinding; private MainActivity activity; private boolean displayUpArrow; + private final ActivityResultLauncher<String> chooseOpmlImportPathLauncher = + registerForActivityResult(new GetContent(), this::chooseOpmlImportPathResult); + private final ActivityResultLauncher<Uri> addLocalFolderLauncher = + registerForActivityResult(new AddLocalFolder(), this::addLocalFolderResult); + @Override @Nullable public View onCreateView(@NonNull LayoutInflater inflater, @@ -91,10 +98,7 @@ public class AddFeedFragment extends Fragment { viewBinding.opmlImportButton.setOnClickListener(v -> { try { - Intent intentGetContentAction = new Intent(Intent.ACTION_GET_CONTENT); - intentGetContentAction.addCategory(Intent.CATEGORY_OPENABLE); - intentGetContentAction.setType("*/*"); - startActivityForResult(intentGetContentAction, REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH); + chooseOpmlImportPathLauncher.launch("*/*"); } catch (ActivityNotFoundException e) { e.printStackTrace(); ((MainActivity) getActivity()) @@ -107,9 +111,7 @@ public class AddFeedFragment extends Fragment { return; } try { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivityForResult(intent, REQUEST_CODE_ADD_LOCAL_FOLDER); + addLocalFolderLauncher.launch(null); } catch (ActivityNotFoundException e) { e.printStackTrace(); ((MainActivity) getActivity()) @@ -157,6 +159,10 @@ public class AddFeedFragment extends Fragment { } private void performSearch() { + viewBinding.combinedFeedSearchEditText.clearFocus(); + InputMethodManager in = (InputMethodManager) + getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + in.hideSoftInputFromWindow(viewBinding.combinedFeedSearchEditText.getWindowToken(), 0); String query = viewBinding.combinedFeedSearchEditText.getText().toString(); if (query.matches("http[s]?://.*")) { addUrl(query); @@ -171,22 +177,23 @@ public class AddFeedFragment extends Fragment { setRetainInstance(true); } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode != Activity.RESULT_OK || data == null) { + private void chooseOpmlImportPathResult(final Uri uri) { + if (uri == null) { return; } - Uri uri = data.getData(); - - if (requestCode == REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH) { - Intent intent = new Intent(getContext(), OpmlImportActivity.class); - intent.setData(uri); - startActivity(intent); - } else if (requestCode == REQUEST_CODE_ADD_LOCAL_FOLDER) { - Observable.fromCallable(() -> addLocalFolder(uri)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( + final Intent intent = new Intent(getContext(), OpmlImportActivity.class); + intent.setData(uri); + startActivity(intent); + } + + private void addLocalFolderResult(final Uri uri) { + if (uri == null) { + return; + } + Observable.fromCallable(() -> addLocalFolder(uri)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( feed -> { Fragment fragment = FeedItemlistFragment.newInstance(feed.getId()); ((MainActivity) getActivity()).loadChildFragment(fragment); @@ -195,7 +202,6 @@ public class AddFeedFragment extends Fragment { ((MainActivity) getActivity()) .showSnackbarAbovePlayer(error.getLocalizedMessage(), Snackbar.LENGTH_LONG); }); - } } private Feed addLocalFolder(Uri uri) throws DownloadRequestException { @@ -219,4 +225,14 @@ public class AddFeedFragment extends Fragment { DBTasks.forceRefreshFeed(getContext(), fromDatabase, true); return fromDatabase; } + + private static class AddLocalFolder extends ActivityResultContracts.OpenDocumentTree { + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @NonNull + @Override + public Intent createIntent(@NonNull final Context context, @Nullable final Uri input) { + return super.createIntent(context, input) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/AudioPlayerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/AudioPlayerFragment.java index 168133c7a..95e2eb1aa 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/AudioPlayerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/AudioPlayerFragment.java @@ -25,6 +25,14 @@ import androidx.viewpager2.widget.ViewPager2; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.snackbar.Snackbar; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.util.playback.PlaybackController; +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.event.playback.SpeedChangedEvent; +import de.danoeh.antennapod.playback.cast.CastEnabledActivity; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; @@ -34,25 +42,20 @@ import java.text.NumberFormat; import java.util.List; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.CastEnabledActivity; import de.danoeh.antennapod.activity.MainActivity; -import de.danoeh.antennapod.core.event.FavoritesEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.ServiceEvent; +import de.danoeh.antennapod.event.FavoritesEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; import de.danoeh.antennapod.model.feed.Chapter; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.core.util.TimeSpeedConverter; -import de.danoeh.antennapod.core.util.playback.MediaPlayerError; import de.danoeh.antennapod.model.playback.Playable; -import de.danoeh.antennapod.core.util.playback.PlaybackController; import de.danoeh.antennapod.dialog.PlaybackControlsDialog; import de.danoeh.antennapod.dialog.SkipPreferenceDialog; import de.danoeh.antennapod.dialog.SleepTimerDialog; @@ -224,8 +227,8 @@ public class AudioPlayerFragment extends Fragment implements } @Subscribe(threadMode = ThreadMode.MAIN) - public void onPlaybackServiceChanged(ServiceEvent event) { - if (event.action == ServiceEvent.Action.SERVICE_SHUT_DOWN) { + public void onPlaybackServiceChanged(PlaybackServiceEvent event) { + if (event.action == PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) { ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED); } } @@ -243,14 +246,11 @@ public class AudioPlayerFragment extends Fragment implements }); } - protected void updatePlaybackSpeedButton(Playable media) { - if (butPlaybackSpeed == null || controller == null) { - return; - } - float speed = PlaybackSpeedUtils.getCurrentPlaybackSpeed(media); - String speedStr = new DecimalFormat("0.00").format(speed); + @Subscribe(threadMode = ThreadMode.MAIN) + public void updatePlaybackSpeedButton(SpeedChangedEvent event) { + String speedStr = new DecimalFormat("0.00").format(event.getNewSpeed()); txtvPlaybackSpeed.setText(speedStr); - butPlaybackSpeed.setSpeed(speed); + butPlaybackSpeed.setSpeed(event.getNewSpeed()); } private void loadMediaInfo(boolean includingChapters) { @@ -282,47 +282,6 @@ public class AudioPlayerFragment extends Fragment implements private PlaybackController newPlaybackController() { return new PlaybackController(getActivity()) { @Override - public void onBufferStart() { - progressIndicator.setVisibility(View.VISIBLE); - } - - @Override - public void onBufferEnd() { - progressIndicator.setVisibility(View.GONE); - } - - @Override - public void onBufferUpdate(float progress) { - if (isStreaming()) { - sbPosition.setSecondaryProgress((int) (progress * sbPosition.getMax())); - } else { - sbPosition.setSecondaryProgress(0); - } - } - - @Override - public void handleError(int code) { - final AlertDialog.Builder errorDialog = new AlertDialog.Builder(getContext()); - errorDialog.setTitle(R.string.error_label); - errorDialog.setMessage(MediaPlayerError.getErrorString(getContext(), code)); - errorDialog.setPositiveButton(android.R.string.ok, (dialog, which) -> - ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED)); - if (!UserPreferences.useExoplayer()) { - errorDialog.setNeutralButton(R.string.media_player_switch_to_exoplayer, (dialog, which) -> { - UserPreferences.enableExoplayer(); - ((MainActivity) getActivity()).showSnackbarAbovePlayer( - R.string.media_player_switched_to_exoplayer, Snackbar.LENGTH_LONG); - }); - } - errorDialog.create().show(); - } - - @Override - public void onSleepTimerUpdate() { - AudioPlayerFragment.this.loadMediaInfo(false); - } - - @Override protected void updatePlayButtonShowsPlay(boolean showPlay) { butPlay.setIsShowPlay(showPlay); } @@ -336,25 +295,28 @@ public class AudioPlayerFragment extends Fragment implements public void onPlaybackEnd() { ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED); } - - @Override - public void onPlaybackSpeedChange() { - updatePlaybackSpeedButton(getMedia()); - } }; } private void updateUi(Playable media) { - if (controller == null) { + if (controller == null || media == null) { return; } duration = controller.getDuration(); - updatePosition(new PlaybackPositionEvent(controller.getPosition(), duration)); - updatePlaybackSpeedButton(media); + updatePosition(new PlaybackPositionEvent(media.getPosition(), media.getDuration())); + updatePlaybackSpeedButton(new SpeedChangedEvent(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media))); setChapterDividers(media); setupOptionsMenu(media); } + @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void sleepTimerUpdate(SleepTimerUpdatedEvent event) { + if (event.isCancelled() || event.wasJustEnabled()) { + AudioPlayerFragment.this.loadMediaInfo(false); + } + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -385,6 +347,20 @@ public class AudioPlayerFragment extends Fragment implements } @Subscribe(threadMode = ThreadMode.MAIN) + @SuppressWarnings("unused") + public void bufferUpdate(BufferUpdateEvent event) { + if (event.hasStarted()) { + progressIndicator.setVisibility(View.VISIBLE); + } else if (event.hasEnded()) { + progressIndicator.setVisibility(View.GONE); + } else if (controller != null && controller.isStreaming()) { + sbPosition.setSecondaryProgress((int) (event.getProgress() * sbPosition.getMax())); + } else { + sbPosition.setSecondaryProgress(0); + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) public void updatePosition(PlaybackPositionEvent event) { if (controller == null || txtvPosition == null || txtvLength == null || sbPosition == null) { return; @@ -419,6 +395,23 @@ public class AudioPlayerFragment extends Fragment implements AudioPlayerFragment.this.loadMediaInfo(false); } + @Subscribe(threadMode = ThreadMode.MAIN) + public void mediaPlayerError(PlayerErrorEvent event) { + final AlertDialog.Builder errorDialog = new AlertDialog.Builder(getContext()); + errorDialog.setTitle(R.string.error_label); + errorDialog.setMessage(event.getMessage()); + errorDialog.setPositiveButton(android.R.string.ok, (dialog, which) -> + ((MainActivity) getActivity()).getBottomSheet().setState(BottomSheetBehavior.STATE_COLLAPSED)); + if (!UserPreferences.useExoplayer()) { + errorDialog.setNeutralButton(R.string.media_player_switch_to_exoplayer, (dialog, which) -> { + UserPreferences.enableExoplayer(); + ((MainActivity) getActivity()).showSnackbarAbovePlayer( + R.string.media_player_switched_to_exoplayer, Snackbar.LENGTH_LONG); + }); + } + errorDialog.create().show(); + } + @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (controller == null || txtvLength == null) { diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java index de14f220e..04ad6e2bd 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java @@ -17,14 +17,14 @@ import androidx.recyclerview.widget.DividerItemDecoration; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import de.danoeh.antennapod.playback.base.PlayerStatus; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import de.danoeh.antennapod.R; import de.danoeh.antennapod.adapter.ChaptersListAdapter; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.util.playback.PlaybackController; import de.danoeh.antennapod.model.feed.Chapter; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java index 6c8baef29..933147378 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/CompletedDownloadsFragment.java @@ -23,10 +23,10 @@ import de.danoeh.antennapod.adapter.EpisodeItemListAdapter; import de.danoeh.antennapod.adapter.actionbutton.DeleteActionButton; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloadLogEvent; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.menuhandler.MenuItemUtils; import de.danoeh.antennapod.fragment.actions.EpisodeMultiSelectActionHandler; import de.danoeh.antennapod.model.feed.FeedItem; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java index 8c2203f72..2d448faa8 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/CoverFragment.java @@ -45,7 +45,7 @@ import org.greenrobot.eventbus.ThreadMode; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; import de.danoeh.antennapod.core.glide.ApGlideSettings; @@ -110,9 +110,8 @@ public class CoverFragment extends Fragment { butNextChapter.setColorFilter(colorFilter); butPrevChapter.setColorFilter(colorFilter); descriptionIcon.setColorFilter(colorFilter); - ChaptersFragment chaptersFragment = new ChaptersFragment(); chapterControl.setOnClickListener(v -> - chaptersFragment.show(getChildFragmentManager(), ChaptersFragment.TAG)); + new ChaptersFragment().show(getChildFragmentManager(), ChaptersFragment.TAG)); butPrevChapter.setOnClickListener(v -> seekToPrevChapter()); butNextChapter.setOnClickListener(v -> seekToNextChapter()); @@ -156,8 +155,13 @@ public class CoverFragment extends Fragment { + "・" + "\u00A0" + StringUtils.replace(StringUtils.stripToEmpty(pubDateStr), " ", "\u00A0")); - Intent openFeed = MainActivity.getIntentToOpenFeed(requireContext(), ((FeedMedia) media).getItem().getFeedId()); - txtvPodcastTitle.setOnClickListener(v -> startActivity(openFeed)); + if (media instanceof FeedMedia) { + Intent openFeed = MainActivity.getIntentToOpenFeed(requireContext(), + ((FeedMedia) media).getItem().getFeedId()); + txtvPodcastTitle.setOnClickListener(v -> startActivity(openFeed)); + } else { + txtvPodcastTitle.setOnClickListener(null); + } txtvPodcastTitle.setOnLongClickListener(v -> copyText(media.getFeedTitle())); txtvEpisodeTitle.setText(media.getEpisodeTitle()); txtvEpisodeTitle.setOnLongClickListener(v -> copyText(media.getEpisodeTitle())); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DiscoveryFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DiscoveryFragment.java index 034b111e1..230a0ce0d 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/DiscoveryFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/DiscoveryFragment.java @@ -23,7 +23,7 @@ import org.greenrobot.eventbus.EventBus; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.OnlineFeedViewActivity; import de.danoeh.antennapod.adapter.itunes.ItunesAdapter; -import de.danoeh.antennapod.core.event.DiscoveryDefaultUpdateEvent; +import de.danoeh.antennapod.event.DiscoveryDefaultUpdateEvent; import de.danoeh.antennapod.discovery.ItunesTopListLoader; import de.danoeh.antennapod.discovery.PodcastSearchResult; import io.reactivex.disposables.Disposable; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java index ddbf6c078..5602dcb78 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/DownloadLogFragment.java @@ -113,7 +113,7 @@ public class DownloadLogFragment extends ListFragment { if (downloadRequest.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) { FeedMedia media = DBReader.getFeedMedia(downloadRequest.getFeedfileId()); FeedItem feedItem = media.getItem(); - feedItem.setAutoDownload(false); + feedItem.disableAutoDownload(); DBWriter.setFeedItem(feedItem); } } else if (item instanceof DownloadStatus) { diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/EpisodesListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/EpisodesListFragment.java index 7ea76bb8d..7eb0847eb 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/EpisodesListFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/EpisodesListFragment.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.fragment; import android.content.DialogInterface; import android.os.Bundle; +import android.view.KeyEvent; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.RecyclerView; @@ -21,10 +22,10 @@ import android.widget.TextView; import android.widget.Toast; import de.danoeh.antennapod.adapter.EpisodeItemListAdapter; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.menuhandler.MenuItemUtils; import de.danoeh.antennapod.view.EpisodeItemListRecyclerView; import de.danoeh.antennapod.view.viewholder.EpisodeItemViewHolder; @@ -40,7 +41,7 @@ import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.dialog.ConfirmationDialog; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -import de.danoeh.antennapod.core.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedItemEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.core.service.download.DownloadService; import de.danoeh.antennapod.core.storage.DBWriter; @@ -321,6 +322,23 @@ public abstract class EpisodesListFragment extends Fragment { } } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onKeyUp(KeyEvent event) { + if (!isAdded() || !isVisible() || !isMenuVisible()) { + return; + } + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_T: + recyclerView.smoothScrollToPosition(0); + break; + case KeyEvent.KEYCODE_B: + recyclerView.smoothScrollToPosition(listAdapter.getItemCount() - 1); + break; + default: + break; + } + } + protected boolean shouldUpdatedItemRemainInList(FeedItem item) { return true; } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java index 8e070738c..1e24d62f7 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java @@ -9,21 +9,23 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; + +import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import com.bumptech.glide.Glide; import com.bumptech.glide.request.RequestOptions; import com.google.android.material.bottomsheet.BottomSheetBehavior; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.ServiceEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.playback.PlaybackServiceEvent; import de.danoeh.antennapod.model.playback.MediaType; import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.model.playback.Playable; import de.danoeh.antennapod.core.util.playback.PlaybackController; +import de.danoeh.antennapod.playback.base.PlayerStatus; import de.danoeh.antennapod.view.PlayButton; import io.reactivex.Maybe; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -77,8 +79,8 @@ public class ExternalPlayerFragment extends Fragment { } @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); butPlay.setOnClickListener(v -> { if (controller == null) { return; @@ -97,12 +99,6 @@ public class ExternalPlayerFragment extends Fragment { private PlaybackController setupPlaybackController() { return new PlaybackController(getActivity()) { - - @Override - public void onPositionObserverUpdate() { - ExternalPlayerFragment.this.onPositionObserverUpdate(); - } - @Override protected void updatePlayButtonShowsPlay(boolean showPlay) { butPlay.setIsShowPlay(showPlay); @@ -140,13 +136,20 @@ public class ExternalPlayerFragment extends Fragment { } @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(PlaybackPositionEvent event) { - onPositionObserverUpdate(); + public void onPositionObserverUpdate(PlaybackPositionEvent event) { + if (controller == null) { + return; + } else if (controller.getPosition() == PlaybackService.INVALID_TIME + || controller.getDuration() == PlaybackService.INVALID_TIME) { + return; + } + progressBar.setProgress((int) + ((double) controller.getPosition() / controller.getDuration() * 100)); } @Subscribe(threadMode = ThreadMode.MAIN) - public void onPlaybackServiceChanged(ServiceEvent event) { - if (event.action == ServiceEvent.Action.SERVICE_SHUT_DOWN) { + public void onPlaybackServiceChanged(PlaybackServiceEvent event) { + if (event.action == PlaybackServiceEvent.Action.SERVICE_SHUT_DOWN) { ((MainActivity) getActivity()).setPlayerVisible(false); } } @@ -193,7 +196,7 @@ public class ExternalPlayerFragment extends Fragment { ((MainActivity) getActivity()).setPlayerVisible(true); txtvTitle.setText(media.getEpisodeTitle()); feedName.setText(media.getFeedTitle()); - onPositionObserverUpdate(); + onPositionObserverUpdate(new PlaybackPositionEvent(media.getPosition(), media.getDuration())); RequestOptions options = new RequestOptions() .placeholder(R.color.light_gray) @@ -218,15 +221,4 @@ public class ExternalPlayerFragment extends Fragment { ((MainActivity) getActivity()).getBottomSheet().setLocked(false); } } - - private void onPositionObserverUpdate() { - if (controller == null) { - return; - } else if (controller.getPosition() == PlaybackService.INVALID_TIME - || controller.getDuration() == PlaybackService.INVALID_TIME) { - return; - } - progressBar.setProgress((int) - ((double) controller.getPosition() / controller.getDuration() * 100)); - } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FavoriteEpisodesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FavoriteEpisodesFragment.java index 986c417fd..d7bfd404d 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/FavoriteEpisodesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FavoriteEpisodesFragment.java @@ -18,7 +18,7 @@ import org.greenrobot.eventbus.Subscribe; import java.util.List; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.event.FavoritesEvent; +import de.danoeh.antennapod.event.FavoritesEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java index da7e7e633..947b8aa6e 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java @@ -1,6 +1,5 @@ package de.danoeh.antennapod.fragment; -import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.Context; @@ -10,62 +9,56 @@ import android.graphics.LightingColorFilter; import android.net.Uri; import android.os.Build; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.AppCompatDrawableManager; -import androidx.appcompat.widget.Toolbar; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.Fragment; import android.text.TextUtils; -import android.text.format.Formatter; import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.Fragment; import com.bumptech.glide.Glide; import com.bumptech.glide.request.RequestOptions; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.CollapsingToolbarLayout; import com.google.android.material.snackbar.Snackbar; import com.joanzapata.iconify.Iconify; - -import org.apache.commons.lang3.StringUtils; - import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.dialog.DownloadRequestErrorDialogCreator; -import de.danoeh.antennapod.model.feed.Feed; -import de.danoeh.antennapod.model.feed.FeedFunding; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.glide.FastBlurTransformation; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DownloadRequestException; -import de.danoeh.antennapod.core.storage.StatisticsItem; -import de.danoeh.antennapod.core.util.Converter; import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.core.util.syndication.HtmlToPlainText; import de.danoeh.antennapod.fragment.preferences.StatisticsFragment; import de.danoeh.antennapod.menuhandler.FeedMenuHandler; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedFunding; import de.danoeh.antennapod.view.ToolbarIconTintManager; import io.reactivex.Completable; import io.reactivex.Maybe; import io.reactivex.MaybeOnSubscribe; -import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import org.apache.commons.lang3.StringUtils; import java.util.ArrayList; import java.util.HashSet; -import java.util.List; -import java.util.Locale; /** * Displays information about a feed. @@ -74,28 +67,22 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId"; private static final String TAG = "FeedInfoActivity"; - private static final int REQUEST_CODE_ADD_LOCAL_FOLDER = 2; + private final ActivityResultLauncher<Uri> addLocalFolderLauncher = + registerForActivityResult(new AddLocalFolder(), this::addLocalFolderResult); private Feed feed; private Disposable disposable; - private Disposable disposableStatistics; private ImageView imgvCover; private TextView txtvTitle; private TextView txtvDescription; - private TextView lblStatistics; - private TextView txtvPodcastTime; - private TextView txtvPodcastSpace; - private TextView txtvPodcastEpisodeCount; private TextView txtvFundingUrl; private TextView lblSupport; - private Button btnvOpenStatistics; private TextView txtvUrl; private TextView txtvAuthorHeader; private ImageView imgvBackground; private View infoContainer; private View header; private Toolbar toolbar; - private ToolbarIconTintManager iconTintManager; public static FeedInfoFragment newInstance(Feed feed) { FeedInfoFragment fragment = new FeedInfoFragment(); @@ -133,13 +120,13 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic AppBarLayout appBar = root.findViewById(R.id.appBar); CollapsingToolbarLayout collapsingToolbar = root.findViewById(R.id.collapsing_toolbar); - iconTintManager = new ToolbarIconTintManager(getContext(), toolbar, collapsingToolbar) { + ToolbarIconTintManager iconTintManager = new ToolbarIconTintManager(getContext(), toolbar, collapsingToolbar) { @Override protected void doTint(Context themedContext) { toolbar.getMenu().findItem(R.id.visit_website_item) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_web)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_web)); toolbar.getMenu().findItem(R.id.share_parent) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_share)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_share)); } }; iconTintManager.updateTint(); @@ -157,23 +144,20 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic imgvBackground.setColorFilter(new LightingColorFilter(0xff828282, 0x000000)); txtvDescription = root.findViewById(R.id.txtvDescription); - lblStatistics = root.findViewById(R.id.lblStatistics); - txtvPodcastSpace = root.findViewById(R.id.txtvPodcastSpaceUsed); - txtvPodcastEpisodeCount = root.findViewById(R.id.txtvPodcastEpisodeCount); - txtvPodcastTime = root.findViewById(R.id.txtvPodcastTime); - btnvOpenStatistics = root.findViewById(R.id.btnvOpenStatistics); txtvUrl = root.findViewById(R.id.txtvUrl); lblSupport = root.findViewById(R.id.lblSupport); txtvFundingUrl = root.findViewById(R.id.txtvFundingUrl); txtvUrl.setOnClickListener(copyUrlToClipboard); - btnvOpenStatistics.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - StatisticsFragment fragment = new StatisticsFragment(); - ((MainActivity) getActivity()).loadChildFragment(fragment, TransitionEffect.SLIDE); - } + long feedId = getArguments().getLong(EXTRA_FEED_ID); + getParentFragmentManager().beginTransaction().replace(R.id.statisticsFragmentContainer, + FeedStatisticsFragment.newInstance(feedId, false), "feed_statistics_fragment") + .commitAllowingStateLoss(); + + root.findViewById(R.id.btnvOpenStatistics).setOnClickListener(view -> { + StatisticsFragment fragment = new StatisticsFragment(); + ((MainActivity) getActivity()).loadChildFragment(fragment, TransitionEffect.SLIDE); }); return root; @@ -195,7 +179,6 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic .subscribe(result -> { feed = result; showFeed(); - loadStatistics(); }, error -> Log.d(TAG, Log.getStackTraceString(error)), () -> { }); } @@ -270,53 +253,12 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic refreshToolbarState(); } - private void loadStatistics() { - if (disposableStatistics != null) { - disposableStatistics.dispose(); - } - - disposableStatistics = - Observable.fromCallable(() -> { - List<StatisticsItem> statisticsData = DBReader.getStatistics(); - - for (StatisticsItem statisticsItem : statisticsData) { - if (statisticsItem.feed.getId() == feed.getId()) { - return statisticsItem; - } - } - - return null; - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - txtvPodcastTime.setText(Converter.shortLocalizedDuration( - getContext(), result.timePlayed)); - txtvPodcastSpace.setText(Formatter.formatShortFileSize( - getContext(), result.totalDownloadSize)); - txtvPodcastEpisodeCount.setText(String.format(Locale.getDefault(), - "%d%s", result.episodesDownloadCount, - getString(R.string.episodes_suffix))); - }, error -> { - Log.d(TAG, Log.getStackTraceString(error)); - lblStatistics.setVisibility(View.GONE); - txtvPodcastSpace.setVisibility(View.GONE); - txtvPodcastTime.setVisibility(View.GONE); - txtvPodcastEpisodeCount.setVisibility(View.GONE); - btnvOpenStatistics.setVisibility(View.GONE); - }); - } - @Override public void onDestroy() { super.onDestroy(); if (disposable != null) { disposable.dispose(); } - - if (disposableStatistics != null) { - disposableStatistics.dispose(); - } } private void refreshToolbarState() { @@ -351,9 +293,7 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic alert.setMessage(R.string.reconnect_local_folder_warning); alert.setPositiveButton(android.R.string.ok, (dialog, which) -> { try { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivityForResult(intent, REQUEST_CODE_ADD_LOCAL_FOLDER); + addLocalFolderLauncher.launch(null); } catch (ActivityNotFoundException e) { Log.e(TAG, "No activity found. Should never happen..."); } @@ -366,16 +306,11 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic return handled; } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode != Activity.RESULT_OK || data == null) { + private void addLocalFolderResult(final Uri uri) { + if (uri == null) { return; } - Uri uri = data.getData(); - - if (requestCode == REQUEST_CODE_ADD_LOCAL_FOLDER) { - reconnectLocalFolder(uri); - } + reconnectLocalFolder(uri); } private void reconnectLocalFolder(Uri uri) { @@ -401,4 +336,14 @@ public class FeedInfoFragment extends Fragment implements Toolbar.OnMenuItemClic error -> ((MainActivity) getActivity()) .showSnackbarAbovePlayer(error.getLocalizedMessage(), Snackbar.LENGTH_LONG)); } + + private static class AddLocalFolder extends ActivityResultContracts.OpenDocumentTree { + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @NonNull + @Override + public Intent createIntent(@NonNull final Context context, @Nullable final Uri input) { + return super.createIntent(context, input) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java index 0ee60866d..5df8e2ccf 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java @@ -22,9 +22,8 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.widget.AppCompatDrawableManager; +import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.Toolbar; -import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; @@ -52,13 +51,13 @@ import de.danoeh.antennapod.adapter.EpisodeItemListAdapter; import de.danoeh.antennapod.core.dialog.DownloadRequestErrorDialogCreator; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -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.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.QueueEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FavoritesEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.QueueEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.feed.FeedEvent; import de.danoeh.antennapod.core.glide.ApGlideSettings; import de.danoeh.antennapod.core.glide.FastBlurTransformation; @@ -73,7 +72,7 @@ import de.danoeh.antennapod.core.util.FeedItemUtil; import de.danoeh.antennapod.core.util.gui.MoreContentListFooterUtil; import de.danoeh.antennapod.dialog.FilterDialog; import de.danoeh.antennapod.dialog.RemoveFeedDialog; -import de.danoeh.antennapod.dialog.RenameFeedDialog; +import de.danoeh.antennapod.dialog.RenameItemDialog; import de.danoeh.antennapod.fragment.swipeactions.SwipeActions; import de.danoeh.antennapod.fragment.actions.EpisodeMultiSelectActionHandler; import de.danoeh.antennapod.menuhandler.FeedItemMenuHandler; @@ -88,6 +87,8 @@ import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import android.view.KeyEvent; +import androidx.fragment.app.Fragment; /** * Displays a list of FeedItems. @@ -187,13 +188,13 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem @Override protected void doTint(Context themedContext) { toolbar.getMenu().findItem(R.id.sort_items) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_sort)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_sort)); toolbar.getMenu().findItem(R.id.filter_items) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_filter)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_filter)); toolbar.getMenu().findItem(R.id.refresh_item) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_refresh)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_refresh)); toolbar.getMenu().findItem(R.id.action_search) - .setIcon(AppCompatDrawableManager.get().getDrawable(themedContext, R.drawable.ic_search)); + .setIcon(AppCompatResources.getDrawable(themedContext, R.drawable.ic_search)); } }; iconTintManager.updateTint(); @@ -330,11 +331,11 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem } final int itemId = item.getItemId(); if (itemId == R.id.rename_item) { - new RenameFeedDialog(getActivity(), feed).show(); + new RenameItemDialog(getActivity(), feed).show(); return true; } else if (itemId == R.id.remove_item) { - RemoveFeedDialog.show(getContext(), feed, () -> - ((MainActivity) getActivity()).loadFragment(EpisodesFragment.TAG, null)); + ((MainActivity) getActivity()).loadFragment(EpisodesFragment.TAG, null); + RemoveFeedDialog.show(getContext(), feed); return true; } else if (itemId == R.id.action_search) { ((MainActivity) getActivity()).loadChildFragment(SearchFragment.newInstance(feed.getId(), feed.getTitle())); @@ -644,6 +645,23 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem return feed; } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onKeyUp(KeyEvent event) { + if (!isAdded() || !isVisible() || !isMenuVisible()) { + return; + } + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_T: + recyclerView.smoothScrollToPosition(0); + break; + case KeyEvent.KEYCODE_B: + recyclerView.smoothScrollToPosition(adapter.getItemCount() - 1); + break; + default: + break; + } + } + private static class FeedItemListAdapter extends EpisodeItemListAdapter { public FeedItemListAdapter(MainActivity mainActivity) { super(mainActivity); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java index dbc7f2ae3..0c2103d25 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java @@ -7,16 +7,19 @@ import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.preference.ListPreference; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.SwitchPreferenceCompat; import androidx.recyclerview.widget.RecyclerView; import de.danoeh.antennapod.R; -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.event.settings.SkipIntroEndingChangedEvent; +import de.danoeh.antennapod.event.settings.SpeedPresetChangedEvent; +import de.danoeh.antennapod.event.settings.VolumeAdaptionChangedEvent; +import de.danoeh.antennapod.databinding.PlaybackSpeedFeedSettingDialogBinding; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedFilter; import de.danoeh.antennapod.model.feed.FeedPreferences; @@ -35,12 +38,9 @@ import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import org.greenrobot.eventbus.EventBus; -import java.text.DecimalFormat; -import java.text.DecimalFormatSymbols; +import java.util.Collections; import java.util.Locale; -import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL; - public class FeedSettingsFragment extends Fragment { private static final String TAG = "FeedSettingsFragment"; private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId"; @@ -104,8 +104,6 @@ public class FeedSettingsFragment extends Fragment { private static final String PREF_FEED_PLAYBACK_SPEED = "feedPlaybackSpeed"; private static final String PREF_AUTO_SKIP = "feedAutoSkip"; private static final String PREF_TAGS = "tags"; - private static final DecimalFormat SPEED_FORMAT = - new DecimalFormat("0.00", DecimalFormatSymbols.getInstance(Locale.US)); private Feed feed; private Disposable disposable; @@ -164,7 +162,6 @@ public class FeedSettingsFragment extends Fragment { updateAutoDeleteSummary(); updateVolumeReductionValue(); updateAutoDownloadEnabled(); - updatePlaybackSpeedPreference(); if (feed.isLocalFeed()) { findPreference(PREF_AUTHENTICATION).setVisible(false); @@ -205,27 +202,34 @@ public class FeedSettingsFragment extends Fragment { } private void setupPlaybackSpeedPreference() { - ListPreference feedPlaybackSpeedPreference = findPreference(PREF_FEED_PLAYBACK_SPEED); - - final String[] speeds = getResources().getStringArray(R.array.playback_speed_values); - String[] values = new String[speeds.length + 1]; - values[0] = SPEED_FORMAT.format(SPEED_USE_GLOBAL); - - String[] entries = new String[speeds.length + 1]; - entries[0] = getString(R.string.feed_auto_download_global); - - System.arraycopy(speeds, 0, values, 1, speeds.length); - System.arraycopy(speeds, 0, entries, 1, speeds.length); - - feedPlaybackSpeedPreference.setEntryValues(values); - feedPlaybackSpeedPreference.setEntries(entries); - feedPlaybackSpeedPreference.setOnPreferenceChangeListener((preference, newValue) -> { - feedPreferences.setFeedPlaybackSpeed(Float.parseFloat((String) newValue)); - DBWriter.setFeedPreferences(feedPreferences); - updatePlaybackSpeedPreference(); - EventBus.getDefault().post( - new SpeedPresetChangedEvent(feedPreferences.getFeedPlaybackSpeed(), feed.getId())); - return false; + Preference feedPlaybackSpeedPreference = findPreference(PREF_FEED_PLAYBACK_SPEED); + feedPlaybackSpeedPreference.setOnPreferenceClickListener(preference -> { + PlaybackSpeedFeedSettingDialogBinding viewBinding = + PlaybackSpeedFeedSettingDialogBinding.inflate(getLayoutInflater()); + viewBinding.seekBar.setProgressChangedListener(speed -> + viewBinding.currentSpeedLabel.setText(String.format(Locale.getDefault(), "%.2fx", speed))); + float speed = feedPreferences.getFeedPlaybackSpeed(); + viewBinding.useGlobalCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + viewBinding.seekBar.setEnabled(!isChecked); + viewBinding.seekBar.setAlpha(isChecked ? 0.4f : 1f); + viewBinding.currentSpeedLabel.setAlpha(isChecked ? 0.4f : 1f); + }); + viewBinding.useGlobalCheckbox.setChecked(speed == FeedPreferences.SPEED_USE_GLOBAL); + viewBinding.seekBar.updateSpeed(speed == FeedPreferences.SPEED_USE_GLOBAL ? 1 : speed); + new AlertDialog.Builder(getContext()) + .setTitle(R.string.playback_speed) + .setView(viewBinding.getRoot()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + float newSpeed = viewBinding.useGlobalCheckbox.isChecked() + ? FeedPreferences.SPEED_USE_GLOBAL : viewBinding.seekBar.getCurrentSpeed(); + feedPreferences.setFeedPlaybackSpeed(newSpeed); + DBWriter.setFeedPreferences(feedPreferences); + EventBus.getDefault().post( + new SpeedPresetChangedEvent(feedPreferences.getFeedPlaybackSpeed(), feed.getId())); + }) + .setNegativeButton(R.string.cancel_label, null) + .show(); + return true; }); } @@ -277,13 +281,6 @@ public class FeedSettingsFragment extends Fragment { }); } - private void updatePlaybackSpeedPreference() { - ListPreference feedPlaybackSpeedPreference = findPreference(PREF_FEED_PLAYBACK_SPEED); - - float speedValue = feedPreferences.getFeedPlaybackSpeed(); - feedPlaybackSpeedPreference.setValue(SPEED_FORMAT.format(speedValue)); - } - private void updateAutoDeleteSummary() { ListPreference autoDeletePreference = findPreference(PREF_AUTO_DELETE); @@ -395,7 +392,8 @@ public class FeedSettingsFragment extends Fragment { private void setupTags() { findPreference(PREF_TAGS).setOnPreferenceClickListener(preference -> { - TagSettingsDialog.newInstance(feedPreferences).show(getChildFragmentManager(), TagSettingsDialog.TAG); + TagSettingsDialog.newInstance(Collections.singletonList(feedPreferences)) + .show(getChildFragmentManager(), TagSettingsDialog.TAG); return true; }); } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsDialogFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsDialogFragment.java new file mode 100644 index 000000000..33710b2c4 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsDialogFragment.java @@ -0,0 +1,42 @@ +package de.danoeh.antennapod.fragment; + +import android.app.Dialog; +import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import de.danoeh.antennapod.R; + +public class FeedStatisticsDialogFragment extends DialogFragment { + private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId"; + private static final String EXTRA_FEED_TITLE = "de.danoeh.antennapod.extra.feedTitle"; + + public static FeedStatisticsDialogFragment newInstance(long feedId, String feedTitle) { + FeedStatisticsDialogFragment fragment = new FeedStatisticsDialogFragment(); + Bundle arguments = new Bundle(); + arguments.putLong(EXTRA_FEED_ID, feedId); + arguments.putString(EXTRA_FEED_TITLE, feedTitle); + fragment.setArguments(arguments); + return fragment; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + AlertDialog.Builder dialog = new AlertDialog.Builder(getContext()); + dialog.setPositiveButton(android.R.string.ok, null); + dialog.setTitle(getArguments().getString(EXTRA_FEED_TITLE)); + dialog.setView(R.layout.feed_statistics_dialog); + return dialog.create(); + } + + @Override + public void onStart() { + super.onStart(); + long feedId = getArguments().getLong(EXTRA_FEED_ID); + getChildFragmentManager().beginTransaction().replace(R.id.statisticsContainer, + FeedStatisticsFragment.newInstance(feedId, true), "feed_statistics_fragment") + .commitAllowingStateLoss(); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsFragment.java new file mode 100644 index 000000000..e85c2a386 --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedStatisticsFragment.java @@ -0,0 +1,93 @@ +package de.danoeh.antennapod.fragment; + +import android.os.Bundle; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import de.danoeh.antennapod.core.storage.DBReader; +import de.danoeh.antennapod.core.storage.StatisticsItem; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.databinding.FeedStatisticsBinding; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +import java.util.List; +import java.util.Locale; + +public class FeedStatisticsFragment extends Fragment { + private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId"; + private static final String EXTRA_DETAILED = "de.danoeh.antennapod.extra.detailed"; + + private long feedId; + private Disposable disposable; + private FeedStatisticsBinding viewBinding; + + public static FeedStatisticsFragment newInstance(long feedId, boolean detailed) { + FeedStatisticsFragment fragment = new FeedStatisticsFragment(); + Bundle arguments = new Bundle(); + arguments.putLong(EXTRA_FEED_ID, feedId); + arguments.putBoolean(EXTRA_DETAILED, detailed); + fragment.setArguments(arguments); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + feedId = getArguments().getLong(EXTRA_FEED_ID); + viewBinding = FeedStatisticsBinding.inflate(inflater); + + if (!getArguments().getBoolean(EXTRA_DETAILED)) { + for (int i = 0; i < viewBinding.getRoot().getChildCount(); i++) { + View child = viewBinding.getRoot().getChildAt(i); + if ("detailed".equals(child.getTag())) { + child.setVisibility(View.GONE); + } + } + } + + loadStatistics(); + return viewBinding.getRoot(); + } + + private void loadStatistics() { + disposable = + Observable.fromCallable(() -> { + List<StatisticsItem> statisticsData = DBReader.getStatistics(); + for (StatisticsItem statisticsItem : statisticsData) { + if (statisticsItem.feed.getId() == feedId) { + return statisticsItem; + } + } + return null; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::showStats, Throwable::printStackTrace); + } + + private void showStats(StatisticsItem s) { + viewBinding.startedTotalLabel.setText(String.format(Locale.getDefault(), "%d / %d", + s.episodesStarted, s.episodes)); + viewBinding.timePlayedLabel.setText(Converter.shortLocalizedDuration(getContext(), s.timePlayed)); + viewBinding.durationPlayedLabel.setText(Converter.shortLocalizedDuration(getContext(), s.timePlayedCountAll)); + viewBinding.totalDurationLabel.setText(Converter.shortLocalizedDuration(getContext(), s.time)); + viewBinding.onDeviceLabel.setText(String.format(Locale.getDefault(), "%d", s.episodesDownloadCount)); + viewBinding.spaceUsedLabel.setText(Formatter.formatShortFileSize(getContext(), s.totalDownloadSize)); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposable != null) { + disposable.dispose(); + } + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java index 31c6da8cd..7361c8527 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java @@ -14,11 +14,7 @@ import android.widget.Button; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.text.TextUtilsCompat; -import androidx.core.util.ObjectsCompat; -import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.bitmap.FitCenter; @@ -42,9 +38,9 @@ import de.danoeh.antennapod.adapter.actionbutton.StreamActionButton; import de.danoeh.antennapod.adapter.actionbutton.VisitWebsiteActionButton; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; @@ -72,6 +68,7 @@ import org.greenrobot.eventbus.ThreadMode; import java.util.List; import java.util.Locale; +import java.util.Objects; /** * Displays information about a FeedItem and actions. @@ -149,7 +146,7 @@ public class ItemFragment extends Fragment { webvDescription = layout.findViewById(R.id.webvDescription); webvDescription.setTimecodeSelectedListener(time -> { if (controller != null && item.getMedia() != null && controller.getMedia() != null - && ObjectsCompat.equals(item.getMedia().getIdentifier(), controller.getMedia().getIdentifier())) { + && Objects.equals(item.getMedia().getIdentifier(), controller.getMedia().getIdentifier())) { controller.seekTo(time); } else { ((MainActivity) getActivity()).showSnackbarAbovePlayer(R.string.play_this_to_seek_position, @@ -190,8 +187,8 @@ public class ItemFragment extends Fragment { } private void showOnDemandConfigBalloon(boolean offerStreaming) { - boolean isLocaleRtl = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) - == ViewCompat.LAYOUT_DIRECTION_RTL; + boolean isLocaleRtl = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) + == View.LAYOUT_DIRECTION_RTL; Balloon balloon = new Balloon.Builder(getContext()) .setArrowOrientation(ArrowOrientation.TOP) .setArrowPosition(0.25f + ((isLocaleRtl ^ offerStreaming) ? 0f : 0.5f)) @@ -224,17 +221,6 @@ public class ItemFragment extends Fragment { } @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - load(); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - } - - @Override public void onStart() { super.onStart(); EventBus.getDefault().register(this); @@ -245,6 +231,7 @@ public class ItemFragment extends Fragment { } }; controller.init(); + load(); } @Override @@ -398,7 +385,7 @@ public class ItemFragment extends Fragment { long mediaId = item.getMedia().getId(); if (ArrayUtils.contains(update.mediaIds, mediaId)) { if (itemsLoaded && getActivity() != null) { - updateAppearance(); + updateButtons(); } } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemPagerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemPagerFragment.java index d42300ca7..cae49c63e 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemPagerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemPagerFragment.java @@ -9,7 +9,6 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; -import androidx.core.view.ViewCompat; import androidx.fragment.app.Fragment; import androidx.viewpager2.adapter.FragmentStateAdapter; import androidx.viewpager2.widget.ViewPager2; @@ -20,7 +19,7 @@ import org.greenrobot.eventbus.ThreadMode; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; -import de.danoeh.antennapod.core.event.FeedItemEvent; +import de.danoeh.antennapod.event.FeedItemEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.menuhandler.FeedItemMenuHandler; @@ -78,7 +77,7 @@ public class ItemPagerFragment extends Fragment implements Toolbar.OnMenuItemCli // > When using FragmentStatePagerAdapter the host ViewPager must have a valid ID set. // When opening multiple ItemPagerFragments by clicking "item" -> "visit podcast" -> "item" -> etc, // the ID is no longer unique and FragmentStatePagerAdapter does not display any pages. - int newId = ViewCompat.generateViewId(); + int newId = View.generateViewId(); if (savedInstanceState != null && savedInstanceState.getInt(KEY_PAGER_ID, 0) != 0) { // Restore state by using the same ID as before. ID collisions are prevented in MainActivity. newId = savedInstanceState.getInt(KEY_PAGER_ID, 0); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java index 826a7e0ab..a5cabeb29 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java @@ -28,9 +28,9 @@ import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.activity.PreferenceActivity; import de.danoeh.antennapod.adapter.NavListAdapter; import de.danoeh.antennapod.core.dialog.ConfirmationDialog; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.QueueEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.QueueEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.dialog.TagSettingsDialog; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -39,7 +39,7 @@ import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.NavDrawerData; import de.danoeh.antennapod.dialog.RemoveFeedDialog; import de.danoeh.antennapod.dialog.SubscriptionsFilterDialog; -import de.danoeh.antennapod.dialog.RenameFeedDialog; +import de.danoeh.antennapod.dialog.RenameItemDialog; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -50,6 +50,7 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -123,24 +124,28 @@ public class NavDrawerFragment extends Fragment implements SharedPreferences.OnS @Override public void onCreateContextMenu(@NonNull ContextMenu menu, @NonNull View v, ContextMenu.ContextMenuInfo menuInfo) { super.onCreateContextMenu(menu, v, menuInfo); - if (contextPressedItem.type != NavDrawerData.DrawerItem.Type.FEED) { - return; // Should actually never happen because the context menu is not set up for other items - } - MenuInflater inflater = getActivity().getMenuInflater(); - inflater.inflate(R.menu.nav_feed_context, menu); - menu.setHeaderTitle(((NavDrawerData.FeedDrawerItem) contextPressedItem).feed.getTitle()); - // episodes are not loaded, so we cannot check if the podcast has new or unplayed ones! + menu.setHeaderTitle(contextPressedItem.getTitle()); + if (contextPressedItem.type == NavDrawerData.DrawerItem.Type.FEED) { + inflater.inflate(R.menu.nav_feed_context, menu); + // episodes are not loaded, so we cannot check if the podcast has new or unplayed ones! + } else { + inflater.inflate(R.menu.nav_folder_context, menu); + } } @Override public boolean onContextItemSelected(@NonNull MenuItem item) { NavDrawerData.DrawerItem pressedItem = contextPressedItem; contextPressedItem = null; - if (pressedItem != null && pressedItem.type == NavDrawerData.DrawerItem.Type.FEED) { + if (pressedItem == null) { + return false; + } + if (pressedItem.type == NavDrawerData.DrawerItem.Type.FEED) { return onFeedContextMenuClicked(((NavDrawerData.FeedDrawerItem) pressedItem).feed, item); + } else { + return onTagContextMenuClicked(pressedItem, item); } - return false; } private boolean onFeedContextMenuClicked(Feed feed, MenuItem item) { @@ -157,16 +162,25 @@ public class NavDrawerFragment extends Fragment implements SharedPreferences.OnS }; removeAllNewFlagsConfirmationDialog.createNewDialog().show(); return true; - } else if (itemId == R.id.add_to_folder) { - TagSettingsDialog.newInstance(feed.getPreferences()).show(getChildFragmentManager(), TagSettingsDialog.TAG); + } else if (itemId == R.id.edit_tags) { + TagSettingsDialog.newInstance(Collections.singletonList(feed.getPreferences())) + .show(getChildFragmentManager(), TagSettingsDialog.TAG); return true; } else if (itemId == R.id.rename_item) { - new RenameFeedDialog(getActivity(), feed).show(); + new RenameItemDialog(getActivity(), feed).show(); return true; } else if (itemId == R.id.remove_item) { - RemoveFeedDialog.show(getContext(), feed, () -> { - ((MainActivity) getActivity()).loadFragment(EpisodesFragment.TAG, null); - }); + ((MainActivity) getActivity()).loadFragment(EpisodesFragment.TAG, null); + RemoveFeedDialog.show(getContext(), feed); + return true; + } + return super.onContextItemSelected(item); + } + + private boolean onTagContextMenuClicked(NavDrawerData.DrawerItem drawerItem, MenuItem item) { + final int itemId = item.getItemId(); + if (itemId == R.id.rename_folder_item) { + new RenameItemDialog(getActivity(), drawerItem).show(); return true; } return super.onContextItemSelected(item); @@ -318,7 +332,7 @@ public class NavDrawerFragment extends Fragment implements SharedPreferences.OnS ((MainActivity) getActivity()).getBottomSheet() .setState(BottomSheetBehavior.STATE_COLLAPSED); } else { - NavDrawerData.FolderDrawerItem folder = ((NavDrawerData.FolderDrawerItem) clickedItem); + NavDrawerData.TagDrawerItem folder = ((NavDrawerData.TagDrawerItem) clickedItem); if (openFolders.contains(folder.name)) { openFolders.remove(folder.name); } else { @@ -388,11 +402,11 @@ public class NavDrawerFragment extends Fragment implements SharedPreferences.OnS for (NavDrawerData.DrawerItem item : items) { item.setLayer(layer); flatItems.add(item); - if (item.type == NavDrawerData.DrawerItem.Type.FOLDER) { - NavDrawerData.FolderDrawerItem folder = ((NavDrawerData.FolderDrawerItem) item); + if (item.type == NavDrawerData.DrawerItem.Type.TAG) { + NavDrawerData.TagDrawerItem folder = ((NavDrawerData.TagDrawerItem) item); folder.isOpen = openFolders.contains(folder.name); if (folder.isOpen) { - flatItems.addAll(makeFlatDrawerData(((NavDrawerData.FolderDrawerItem) item).children, layer + 1)); + flatItems.addAll(makeFlatDrawerData(((NavDrawerData.TagDrawerItem) item).children, layer + 1)); } } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/OnlineSearchFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/OnlineSearchFragment.java index 992b6930c..f3080f655 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/OnlineSearchFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/OnlineSearchFragment.java @@ -1,15 +1,20 @@ package de.danoeh.antennapod.fragment; +import android.content.Context; import android.content.Intent; import android.os.Bundle; + +import android.widget.AbsListView; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.Fragment; import androidx.appcompat.widget.SearchView; + import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.GridView; import android.widget.ProgressBar; @@ -110,6 +115,21 @@ public class OnlineSearchFragment extends Fragment { TextView txtvPoweredBy = root.findViewById(R.id.search_powered_by); txtvPoweredBy.setText(getString(R.string.search_powered_by, searchProvider.getName())); setupToolbar(root.findViewById(R.id.toolbar)); + + gridView.setOnScrollListener(new AbsListView.OnScrollListener() { + @Override + public void onScrollStateChanged(AbsListView view, int scrollState) { + if (scrollState == SCROLL_STATE_TOUCH_SCROLL) { + InputMethodManager imm = (InputMethodManager) + getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + + @Override + public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { + } + }); return root; } @@ -142,6 +162,11 @@ public class OnlineSearchFragment extends Fragment { return false; } }); + sv.setOnQueryTextFocusChangeListener((view, hasFocus) -> { + if (hasFocus) { + showInputMethod(view.findFocus()); + } + }); searchItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { @Override public boolean onMenuItemActionExpand(MenuItem item) { @@ -192,4 +217,11 @@ public class OnlineSearchFragment extends Fragment { txtvEmpty.setVisibility(View.GONE); progressBar.setVisibility(View.VISIBLE); } + + private void showInputMethod(View view) { + InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(view, 0); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java index 5e3d36c03..54c98c0ce 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/PlaybackHistoryFragment.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.fragment; import android.os.Bundle; import android.util.Log; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; @@ -16,11 +17,11 @@ import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.adapter.EpisodeItemListAdapter; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.PlaybackHistoryEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.playback.PlaybackHistoryEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; @@ -181,6 +182,23 @@ public class PlaybackHistoryFragment extends Fragment implements Toolbar.OnMenuI } @Subscribe(threadMode = ThreadMode.MAIN) + public void onKeyUp(KeyEvent event) { + if (!isAdded() || !isVisible() || !isMenuVisible()) { + return; + } + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_T: + recyclerView.smoothScrollToPosition(0); + break; + case KeyEvent.KEYCODE_B: + recyclerView.smoothScrollToPosition(adapter.getItemCount() - 1); + break; + default: + break; + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) public void onHistoryUpdated(PlaybackHistoryEvent event) { loadItems(); refreshToolbarState(); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java index 1b7d236c6..c145642b9 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/QueueFragment.java @@ -7,6 +7,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; +import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; @@ -33,11 +34,11 @@ import de.danoeh.antennapod.adapter.QueueRecyclerAdapter; import de.danoeh.antennapod.core.dialog.ConfirmationDialog; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.QueueEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.QueueEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.menuhandler.MenuItemUtils; import de.danoeh.antennapod.fragment.actions.EpisodeMultiSelectActionHandler; import de.danoeh.antennapod.fragment.swipeactions.SwipeActions; @@ -234,6 +235,23 @@ public class QueueFragment extends Fragment implements Toolbar.OnMenuItemClickLi } } + @Subscribe(threadMode = ThreadMode.MAIN) + public void onKeyUp(KeyEvent event) { + if (!isAdded() || !isVisible() || !isMenuVisible()) { + return; + } + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_T: + recyclerView.smoothScrollToPosition(0); + break; + case KeyEvent.KEYCODE_B: + recyclerView.smoothScrollToPosition(recyclerAdapter.getItemCount() - 1); + break; + default: + break; + } + } + @Override public void onDestroyView() { super.onDestroyView(); @@ -247,8 +265,9 @@ public class QueueFragment extends Fragment implements Toolbar.OnMenuItemClickLi () -> DownloadService.isRunning && DownloadRequester.getInstance().isDownloadingFeeds(); private void refreshToolbarState() { - toolbar.getMenu().findItem(R.id.queue_lock).setChecked(UserPreferences.isQueueLocked()); boolean keepSorted = UserPreferences.isQueueKeepSorted(); + toolbar.getMenu().findItem(R.id.queue_lock).setChecked(UserPreferences.isQueueLocked()); + toolbar.getMenu().findItem(R.id.queue_lock).setVisible(!keepSorted); toolbar.getMenu().findItem(R.id.queue_sort_random).setVisible(!keepSorted); toolbar.getMenu().findItem(R.id.queue_keep_sorted).setChecked(keepSorted); isUpdatingFeeds = MenuItemUtils.updateRefreshMenuItem(toolbar.getMenu(), @@ -635,11 +654,6 @@ public class QueueFragment extends Fragment implements Toolbar.OnMenuItemClickLi } @Override - public boolean isItemViewSwipeEnabled() { - return !UserPreferences.isQueueLocked(); - } - - @Override public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); // Check if drag finished diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/QuickFeedDiscoveryFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/QuickFeedDiscoveryFragment.java index 14f355b52..8bfcfd1ed 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/QuickFeedDiscoveryFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/QuickFeedDiscoveryFragment.java @@ -25,7 +25,7 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.activity.OnlineFeedViewActivity; import de.danoeh.antennapod.adapter.FeedDiscoverAdapter; -import de.danoeh.antennapod.core.event.DiscoveryDefaultUpdateEvent; +import de.danoeh.antennapod.event.DiscoveryDefaultUpdateEvent; import de.danoeh.antennapod.discovery.ItunesTopListLoader; import de.danoeh.antennapod.discovery.PodcastSearchResult; import io.reactivex.disposables.Disposable; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java index f8326d9c1..e43b6f314 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/SearchFragment.java @@ -1,5 +1,7 @@ package de.danoeh.antennapod.fragment; + +import android.content.Context; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -9,6 +11,7 @@ import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; import android.widget.ProgressBar; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -26,10 +29,10 @@ import de.danoeh.antennapod.adapter.EpisodeItemListAdapter; import de.danoeh.antennapod.adapter.FeedSearchResultAdapter; import de.danoeh.antennapod.core.event.DownloadEvent; import de.danoeh.antennapod.core.event.DownloaderUpdate; -import de.danoeh.antennapod.core.event.FeedItemEvent; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedItemEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.model.feed.Feed; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.core.storage.FeedSearcher; @@ -70,7 +73,6 @@ public class SearchFragment extends Fragment { private SearchView searchView; private Handler automaticSearchDebouncer; private long lastQueryChange = 0; - /** * Create a new SearchFragment that searches all feeds. */ @@ -153,6 +155,22 @@ public class SearchFragment extends Fragment { if (getArguments().getString(ARG_QUERY, null) != null) { search(); } + searchView.setOnQueryTextFocusChangeListener((view, hasFocus) -> { + if (hasFocus) { + showInputMethod(view.findFocus()); + } + }); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { + super.onScrollStateChanged(recyclerView, newState); + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + InputMethodManager imm = (InputMethodManager) + getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(recyclerView.getWindowToken(), 0); + } + } + }); return layout; } @@ -320,4 +338,11 @@ public class SearchFragment extends Fragment { List<Feed> feeds = FeedSearcher.searchFeeds(getContext(), query); return new Pair<>(items, feeds); } + + private void showInputMethod(View view) { + InputMethodManager imm = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(view, 0); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java index ea6c2ca0d..200f4dcd6 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java @@ -33,6 +33,7 @@ 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.Locale; import java.util.concurrent.Callable; @@ -42,8 +43,8 @@ import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.adapter.SubscriptionsRecyclerAdapter; import de.danoeh.antennapod.core.dialog.ConfirmationDialog; import de.danoeh.antennapod.core.event.DownloadEvent; -import de.danoeh.antennapod.core.event.FeedListUpdateEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.FeedListUpdateEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.menuhandler.MenuItemUtils; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.DownloadService; @@ -54,7 +55,7 @@ import de.danoeh.antennapod.core.storage.NavDrawerData; import de.danoeh.antennapod.core.util.download.AutoUpdateManager; import de.danoeh.antennapod.dialog.FeedSortDialog; import de.danoeh.antennapod.dialog.RemoveFeedDialog; -import de.danoeh.antennapod.dialog.RenameFeedDialog; +import de.danoeh.antennapod.dialog.RenameItemDialog; import de.danoeh.antennapod.dialog.SubscriptionsFilterDialog; import de.danoeh.antennapod.fragment.actions.FeedMultiSelectActionHandler; import de.danoeh.antennapod.model.feed.Feed; @@ -250,8 +251,8 @@ public class SubscriptionFragment extends Fragment } @Override - public void onActivityCreated(Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); + public void onViewCreated(@NonNull View v, Bundle savedInstanceState) { + super.onViewCreated(v, savedInstanceState); subscriptionAdapter = new SubscriptionsRecyclerAdapter((MainActivity) getActivity()); subscriptionAdapter.setOnSelectModeListener(this); subscriptionRecycler.setAdapter(subscriptionAdapter); @@ -293,9 +294,9 @@ public class SubscriptionFragment extends Fragment NavDrawerData data = DBReader.getNavDrawerData(); List<NavDrawerData.DrawerItem> items = data.items; for (NavDrawerData.DrawerItem item : items) { - if (item.type == NavDrawerData.DrawerItem.Type.FOLDER + if (item.type == NavDrawerData.DrawerItem.Type.TAG && item.getTitle().equals(displayedFolder)) { - return ((NavDrawerData.FolderDrawerItem) item).children; + return ((NavDrawerData.TagDrawerItem) item).children; } } return items; @@ -333,25 +334,32 @@ public class SubscriptionFragment extends Fragment @Override public boolean onContextItemSelected(MenuItem item) { - Feed feed = subscriptionAdapter.getSelectedFeed(); - if (feed == null) { + NavDrawerData.DrawerItem drawerItem = subscriptionAdapter.getSelectedItem(); + if (drawerItem == null) { return false; } int itemId = item.getItemId(); + if (drawerItem.type == NavDrawerData.DrawerItem.Type.TAG && itemId == R.id.rename_folder_item) { + new RenameItemDialog(getActivity(), drawerItem).show(); + return true; + } + + Feed feed = ((NavDrawerData.FeedDrawerItem) drawerItem).feed; if (itemId == R.id.remove_all_new_flags_item) { displayConfirmationDialog( R.string.remove_all_new_flags_label, R.string.remove_all_new_flags_confirmation_msg, () -> DBWriter.removeFeedNewFlag(feed.getId())); return true; - } else if (itemId == R.id.add_to_folder) { - TagSettingsDialog.newInstance(feed.getPreferences()).show(getChildFragmentManager(), TagSettingsDialog.TAG); + } else if (itemId == R.id.edit_tags) { + TagSettingsDialog.newInstance(Collections.singletonList(feed.getPreferences())) + .show(getChildFragmentManager(), TagSettingsDialog.TAG); return true; } else if (itemId == R.id.rename_item) { - new RenameFeedDialog(getActivity(), feed).show(); + new RenameItemDialog(getActivity(), feed).show(); return true; } else if (itemId == R.id.remove_item) { - RemoveFeedDialog.show(getContext(), feed, null); + RemoveFeedDialog.show(getContext(), feed); return true; } else if (itemId == R.id.multi_select) { speedDialView.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/actions/EpisodeMultiSelectActionHandler.java b/app/src/main/java/de/danoeh/antennapod/fragment/actions/EpisodeMultiSelectActionHandler.java index 028d2fff4..710ec6ce0 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/actions/EpisodeMultiSelectActionHandler.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/actions/EpisodeMultiSelectActionHandler.java @@ -95,16 +95,13 @@ public class EpisodeMultiSelectActionHandler { private void deleteChecked() { int countHasMedia = 0; - int countNoMedia = 0; for (FeedItem feedItem : selectedItems) { if (feedItem.hasMedia() && feedItem.getMedia().isDownloaded()) { countHasMedia++; DBWriter.deleteFeedMediaOfItem(activity, feedItem.getMedia().getId()); - } else { - countNoMedia++; } } - showMessageMore(R.plurals.deleted_multi_episode_batch_label, countNoMedia, countHasMedia); + showMessage(R.plurals.deleted_multi_episode_batch_label, countHasMedia); } private void showMessage(@PluralsRes int msgId, int numItems) { @@ -112,14 +109,6 @@ public class EpisodeMultiSelectActionHandler { .getQuantityString(msgId, numItems, numItems), Snackbar.LENGTH_LONG); } - private void showMessageMore(@PluralsRes int msgId, int countNoMedia, int countHasMedia) { - activity.showSnackbarAbovePlayer(activity.getResources() - .getQuantityString(msgId, - (countHasMedia + countNoMedia), - (countHasMedia + countNoMedia), countHasMedia), - Snackbar.LENGTH_LONG); - } - private long[] getSelectedIds() { long[] checkedIds = new long[selectedItems.size()]; for (int i = 0; i < selectedItems.size(); ++i) { diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/actions/FeedMultiSelectActionHandler.java b/app/src/main/java/de/danoeh/antennapod/fragment/actions/FeedMultiSelectActionHandler.java index f160b2241..e3dfe8ade 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/actions/FeedMultiSelectActionHandler.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/actions/FeedMultiSelectActionHandler.java @@ -3,19 +3,23 @@ package de.danoeh.antennapod.fragment.actions; import android.util.Log; import androidx.annotation.PluralsRes; +import androidx.appcompat.app.AlertDialog; import androidx.core.util.Consumer; import com.google.android.material.snackbar.Snackbar; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; +import java.util.ArrayList; import java.util.List; import java.util.Locale; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.core.storage.DBWriter; +import de.danoeh.antennapod.databinding.PlaybackSpeedFeedSettingDialogBinding; import de.danoeh.antennapod.dialog.RemoveFeedDialog; +import de.danoeh.antennapod.dialog.TagSettingsDialog; import de.danoeh.antennapod.fragment.preferences.dialog.PreferenceListDialog; import de.danoeh.antennapod.fragment.preferences.dialog.PreferenceSwitchDialog; import de.danoeh.antennapod.model.feed.Feed; @@ -33,7 +37,7 @@ public class FeedMultiSelectActionHandler { public void handleAction(int id) { if (id == R.id.remove_item) { - RemoveFeedDialog.show(activity, selectedItems, null); + RemoveFeedDialog.show(activity, selectedItems); } else if (id == R.id.keep_updated) { keepUpdatedPrefHandler(); } else if (id == R.id.autodownload) { @@ -42,6 +46,8 @@ public class FeedMultiSelectActionHandler { autoDeleteEpisodesPrefHandler(); } else if (id == R.id.playback_speed) { playbackSpeedPrefHandler(); + } else if (id == R.id.edit_tags) { + editFeedPrefTags(); } else { Log.e(TAG, "Unrecognized speed dial action item. Do nothing. id=" + id); } @@ -64,25 +70,26 @@ public class FeedMultiSelectActionHandler { new DecimalFormat("0.00", DecimalFormatSymbols.getInstance(Locale.US)); private void playbackSpeedPrefHandler() { - final String[] speeds = activity.getResources().getStringArray(R.array.playback_speed_values); - String[] values = new String[speeds.length + 1]; - values[0] = SPEED_FORMAT.format(FeedPreferences.SPEED_USE_GLOBAL); - - String[] entries = new String[speeds.length + 1]; - entries[0] = activity.getString(R.string.feed_auto_download_global); - - System.arraycopy(speeds, 0, values, 1, speeds.length); - System.arraycopy(speeds, 0, entries, 1, speeds.length); - - PreferenceListDialog preferenceListDialog = new PreferenceListDialog(activity, - activity.getString(R.string.playback_speed)); - preferenceListDialog.openDialog(entries); - preferenceListDialog.setOnPreferenceChangedListener(pos -> { - saveFeedPreferences(feedPreferences -> { - feedPreferences.setFeedPlaybackSpeed(Float.parseFloat((String) values[pos])); - }); - + PlaybackSpeedFeedSettingDialogBinding viewBinding = + PlaybackSpeedFeedSettingDialogBinding.inflate(activity.getLayoutInflater()); + viewBinding.seekBar.setProgressChangedListener(speed -> + viewBinding.currentSpeedLabel.setText(String.format(Locale.getDefault(), "%.2fx", speed))); + viewBinding.useGlobalCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { + viewBinding.seekBar.setEnabled(!isChecked); + viewBinding.seekBar.setAlpha(isChecked ? 0.4f : 1f); + viewBinding.currentSpeedLabel.setAlpha(isChecked ? 0.4f : 1f); }); + viewBinding.seekBar.updateSpeed(1.0f); + new AlertDialog.Builder(activity) + .setTitle(R.string.playback_speed) + .setView(viewBinding.getRoot()) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + float newSpeed = viewBinding.useGlobalCheckbox.isChecked() + ? FeedPreferences.SPEED_USE_GLOBAL : viewBinding.seekBar.getCurrentSpeed(); + saveFeedPreferences(feedPreferences -> feedPreferences.setFeedPlaybackSpeed(newSpeed)); + }) + .setNegativeButton(R.string.cancel_label, null) + .show(); } private void autoDeleteEpisodesPrefHandler() { @@ -136,4 +143,13 @@ public class FeedMultiSelectActionHandler { } showMessage(R.plurals.updated_feeds_batch_label, selectedItems.size()); } + + private void editFeedPrefTags() { + ArrayList<FeedPreferences> preferencesList = new ArrayList<>(); + for (Feed feed : selectedItems) { + preferencesList.add(feed.getPreferences()); + } + TagSettingsDialog.newInstance(preferencesList).show(activity.getSupportFragmentManager(), + TagSettingsDialog.TAG); + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java index c813cbf7a..c2c5adc9a 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/PodcastListFragment.java @@ -15,7 +15,7 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.activity.OnlineFeedViewActivity; import de.danoeh.antennapod.adapter.gpodnet.PodcastListAdapter; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetServiceException; @@ -76,8 +76,8 @@ public abstract class PodcastListFragment extends Fragment { disposable = Observable.fromCallable( () -> { GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(), - GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(), - GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); return loadPodcastData(service); }) .subscribeOn(Schedulers.io()) @@ -101,7 +101,7 @@ public abstract class PodcastListFragment extends Fragment { }, error -> { gridView.setVisibility(View.GONE); progressBar.setVisibility(View.GONE); - txtvError.setText(getString(R.string.error_msg_prefix) + error.getMessage()); + txtvError.setText(error.getMessage()); txtvError.setVisibility(View.VISIBLE); butRetry.setVisibility(View.VISIBLE); Log.e(TAG, Log.getStackTraceString(error)); diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java index f961e30bb..abdfab941 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/gpodnet/TagListFragment.java @@ -8,7 +8,7 @@ import androidx.annotation.NonNull; import androidx.fragment.app.ListFragment; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.adapter.gpodnet.TagListAdapter; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; @@ -51,8 +51,8 @@ public class TagListFragment extends ListFragment { disposable = Observable.fromCallable( () -> { GpodnetService service = new GpodnetService(AntennapodHttpClient.getHttpClient(), - GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(), - GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); return service.getTopTags(COUNT); }) .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/DownloadStatisticsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/DownloadStatisticsFragment.java index 3059d7ad2..ff94cc20c 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/DownloadStatisticsFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/DownloadStatisticsFragment.java @@ -17,7 +17,6 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.adapter.DownloadStatisticsListAdapter; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.StatisticsItem; -import de.danoeh.antennapod.core.util.comparator.CompareCompat; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; @@ -75,7 +74,7 @@ public class DownloadStatisticsFragment extends Fragment { Observable.fromCallable(() -> { List<StatisticsItem> statisticsData = DBReader.getStatistics(); Collections.sort(statisticsData, (item1, item2) -> - CompareCompat.compareLong(item1.totalDownloadSize, item2.totalDownloadSize)); + Long.compare(item2.totalDownloadSize, item1.totalDownloadSize)); return statisticsData; }) .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java deleted file mode 100644 index 4fb734e17..000000000 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderPreferencesFragment.java +++ /dev/null @@ -1,128 +0,0 @@ -package de.danoeh.antennapod.fragment.preferences; - -import android.app.Activity; -import android.os.Bundle; -import androidx.core.text.HtmlCompat; -import androidx.preference.PreferenceFragmentCompat; - -import android.text.Spanned; -import android.text.format.DateUtils; -import com.google.android.material.snackbar.Snackbar; -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.PreferenceActivity; -import de.danoeh.antennapod.core.event.SyncServiceEvent; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; -import de.danoeh.antennapod.core.sync.SyncService; -import de.danoeh.antennapod.dialog.AuthenticationDialog; -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; - -public class GpodderPreferencesFragment extends PreferenceFragmentCompat { - private static final String PREF_GPODNET_LOGIN = "pref_gpodnet_authenticate"; - private static final String PREF_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information"; - private static final String PREF_GPODNET_SYNC = "pref_gpodnet_sync"; - private static final String PREF_GPODNET_FORCE_FULL_SYNC = "pref_gpodnet_force_full_sync"; - private static final String PREF_GPODNET_LOGOUT = "pref_gpodnet_logout"; - - @Override - public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { - addPreferencesFromResource(R.xml.preferences_gpodder); - setupGpodderScreen(); - } - - @Override - public void onStart() { - super.onStart(); - ((PreferenceActivity) getActivity()).getSupportActionBar().setTitle(R.string.gpodnet_main_label); - updateGpodnetPreferenceScreen(); - EventBus.getDefault().register(this); - } - - @Override - public void onStop() { - super.onStop(); - EventBus.getDefault().unregister(this); - ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(""); - } - - @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) - public void syncStatusChanged(SyncServiceEvent event) { - updateGpodnetPreferenceScreen(); - if (!GpodnetPreferences.loggedIn()) { - return; - } - if (event.getMessageResId() == R.string.sync_status_error - || event.getMessageResId() == R.string.sync_status_success) { - updateLastGpodnetSyncReport(SyncService.isLastSyncSuccessful(getContext()), - SyncService.getLastSyncAttempt(getContext())); - } else { - ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(event.getMessageResId()); - } - } - - private void setupGpodderScreen() { - final Activity activity = getActivity(); - - findPreference(PREF_GPODNET_LOGIN).setOnPreferenceClickListener(preference -> { - new GpodderAuthenticationFragment().show(getChildFragmentManager(), GpodderAuthenticationFragment.TAG); - return true; - }); - findPreference(PREF_GPODNET_SETLOGIN_INFORMATION) - .setOnPreferenceClickListener(preference -> { - AuthenticationDialog dialog = new AuthenticationDialog(activity, - R.string.pref_gpodnet_setlogin_information_title, false, GpodnetPreferences.getUsername(), - null) { - - @Override - protected void onConfirmed(String username, String password) { - GpodnetPreferences.setPassword(password); - } - }; - dialog.show(); - return true; - }); - findPreference(PREF_GPODNET_SYNC).setOnPreferenceClickListener(preference -> { - SyncService.syncImmediately(getActivity().getApplicationContext()); - return true; - }); - findPreference(PREF_GPODNET_FORCE_FULL_SYNC).setOnPreferenceClickListener(preference -> { - SyncService.fullSync(getContext()); - return true; - }); - findPreference(PREF_GPODNET_LOGOUT).setOnPreferenceClickListener(preference -> { - GpodnetPreferences.logout(); - Snackbar.make(getView(), R.string.pref_gpodnet_logout_toast, Snackbar.LENGTH_LONG).show(); - updateGpodnetPreferenceScreen(); - return true; - }); - } - - private void updateGpodnetPreferenceScreen() { - final boolean loggedIn = GpodnetPreferences.loggedIn(); - findPreference(PREF_GPODNET_LOGIN).setEnabled(!loggedIn); - findPreference(PREF_GPODNET_SETLOGIN_INFORMATION).setEnabled(loggedIn); - findPreference(PREF_GPODNET_SYNC).setEnabled(loggedIn); - findPreference(PREF_GPODNET_FORCE_FULL_SYNC).setEnabled(loggedIn); - findPreference(PREF_GPODNET_LOGOUT).setEnabled(loggedIn); - if (loggedIn) { - String format = getActivity().getString(R.string.pref_gpodnet_login_status); - String summary = String.format(format, GpodnetPreferences.getUsername(), - GpodnetPreferences.getDeviceID()); - Spanned formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY); - findPreference(PREF_GPODNET_LOGOUT).setSummary(formattedSummary); - updateLastGpodnetSyncReport(SyncService.isLastSyncSuccessful(getContext()), - SyncService.getLastSyncAttempt(getContext())); - } else { - findPreference(PREF_GPODNET_LOGOUT).setSummary(null); - } - } - - private void updateLastGpodnetSyncReport(boolean successful, long lastTime) { - String status = String.format("%1$s (%2$s)", getString(successful - ? R.string.gpodnetsync_pref_report_successful : R.string.gpodnetsync_pref_report_failed), - DateUtils.getRelativeDateTimeString(getContext(), - lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)); - ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(status); - } -} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java index f6aa45e93..5156de432 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java @@ -8,10 +8,15 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.Environment; import android.util.Log; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.activity.result.contract.ActivityResultContracts.GetContent; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.core.content.FileProvider; import androidx.preference.PreferenceFragmentCompat; @@ -35,7 +40,6 @@ import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import java.io.File; -import java.io.FileOutputStream; import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; @@ -54,13 +58,19 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { private static final String DEFAULT_HTML_OUTPUT_NAME = "antennapod-feeds-%s.html"; private static final String CONTENT_TYPE_HTML = "text/html"; private static final String DEFAULT_FAVORITES_OUTPUT_NAME = "antennapod-favorites-%s.html"; - private static final int REQUEST_CODE_CHOOSE_OPML_EXPORT_PATH = 1; - private static final int REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH = 2; - private static final int REQUEST_CODE_CHOOSE_HTML_EXPORT_PATH = 3; - private static final int REQUEST_CODE_RESTORE_DATABASE = 4; - private static final int REQUEST_CODE_BACKUP_DATABASE = 5; - private static final int REQUEST_CODE_CHOOSE_FAVORITES_EXPORT_PATH = 6; private static final String DATABASE_EXPORT_FILENAME = "AntennaPodBackup-%s.db"; + private final ActivityResultLauncher<Intent> chooseOpmlExportPathLauncher = + registerForActivityResult(new StartActivityForResult(), this::chooseOpmlExportPathResult); + private final ActivityResultLauncher<Intent> chooseHtmlExportPathLauncher = + registerForActivityResult(new StartActivityForResult(), this::chooseHtmlExportPathResult); + private final ActivityResultLauncher<Intent> chooseFavoritesExportPathLauncher = + registerForActivityResult(new StartActivityForResult(), this::chooseFavoritesExportPathResult); + private final ActivityResultLauncher<Intent> restoreDatabaseLauncher = + registerForActivityResult(new StartActivityForResult(), this::restoreDatabaseResult); + private final ActivityResultLauncher<String> backupDatabaseLauncher = + registerForActivityResult(new BackupDatabase(), this::backupDatabaseResult); + private final ActivityResultLauncher<String> chooseOpmlImportPathLauncher = + registerForActivityResult(new GetContent(), this::chooseOpmlImportPathResult); private Disposable disposable; private ProgressDialog progressDialog; @@ -95,23 +105,20 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { findPreference(PREF_OPML_EXPORT).setOnPreferenceClickListener( preference -> { openExportPathPicker(CONTENT_TYPE_OPML, dateStampFilename(DEFAULT_OPML_OUTPUT_NAME), - REQUEST_CODE_CHOOSE_OPML_EXPORT_PATH, new OpmlWriter()); + chooseOpmlExportPathLauncher, new OpmlWriter()); return true; } ); findPreference(PREF_HTML_EXPORT).setOnPreferenceClickListener( preference -> { openExportPathPicker(CONTENT_TYPE_HTML, dateStampFilename(DEFAULT_HTML_OUTPUT_NAME), - REQUEST_CODE_CHOOSE_HTML_EXPORT_PATH, new HtmlWriter()); + chooseHtmlExportPathLauncher, new HtmlWriter()); return true; }); findPreference(PREF_OPML_IMPORT).setOnPreferenceClickListener( preference -> { try { - Intent intentGetContentAction = new Intent(Intent.ACTION_GET_CONTENT); - intentGetContentAction.addCategory(Intent.CATEGORY_OPENABLE); - intentGetContentAction.setType("*/*"); - startActivityForResult(intentGetContentAction, REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH); + chooseOpmlImportPathLauncher.launch("*/*"); } catch (ActivityNotFoundException e) { Log.e(TAG, "No activity found. Should never happen..."); } @@ -130,7 +137,7 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { findPreference(PREF_FAVORITE_EXPORT).setOnPreferenceClickListener( preference -> { openExportPathPicker(CONTENT_TYPE_HTML, dateStampFilename(DEFAULT_FAVORITES_OUTPUT_NAME), - REQUEST_CODE_CHOOSE_FAVORITES_EXPORT_PATH, new FavoritesWriter()); + chooseFavoritesExportPathLauncher, new FavoritesWriter()); return true; }); } @@ -159,26 +166,7 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { } private void exportDatabase() { - if (Build.VERSION.SDK_INT >= 19) { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType("application/x-sqlite3") - .putExtra(Intent.EXTRA_TITLE, dateStampFilename(DATABASE_EXPORT_FILENAME)); - - startActivityForResult(intent, REQUEST_CODE_BACKUP_DATABASE); - } else { - File sd = Environment.getExternalStorageDirectory(); - File backupDB = new File(sd, dateStampFilename(DATABASE_EXPORT_FILENAME)); - progressDialog.show(); - disposable = Completable.fromAction(() -> - DatabaseExporter.exportToStream(new FileOutputStream(backupDB), getContext())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - Snackbar.make(getView(), R.string.export_success_title, Snackbar.LENGTH_LONG).show(); - progressDialog.dismiss(); - }, this::showExportErrorDialog); - } + backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME)); } private void importDatabase() { @@ -190,18 +178,10 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { // add a button builder.setNegativeButton(R.string.no, null); builder.setPositiveButton(R.string.confirm_label, (dialog, which) -> { - if (Build.VERSION.SDK_INT >= 19) { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.setType("*/*"); - startActivityForResult(intent, REQUEST_CODE_RESTORE_DATABASE); - } else { - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.setType("*/*"); - startActivityForResult(Intent.createChooser(intent, - getString(R.string.import_select_file)), REQUEST_CODE_RESTORE_DATABASE); - } - } - ); + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.setType("*/*"); + restoreDatabaseLauncher.launch(intent); + }); // create and show the alert dialog builder.show(); @@ -227,15 +207,14 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { sendIntent.putExtra(Intent.EXTRA_STREAM, streamUri); sendIntent.setType("text/plain"); sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { - List<ResolveInfo> resInfoList = getContext().getPackageManager() - .queryIntentActivities(sendIntent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, streamUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - } + Intent chooserIntent = Intent.createChooser(sendIntent, getString(R.string.send_label)); + List<ResolveInfo> resInfoList = getContext().getPackageManager() + .queryIntentActivities(sendIntent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, streamUri, Intent.FLAG_GRANT_READ_URI_PERMISSION); } - getContext().startActivity(Intent.createChooser(sendIntent, getString(R.string.send_label))); + getContext().startActivity(chooserIntent); }); alert.create().show(); } @@ -249,64 +228,97 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { alert.show(); } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if (resultCode != Activity.RESULT_OK || data == null) { + private void chooseOpmlExportPathResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { return; } - Uri uri = data.getData(); + final Uri uri = result.getData().getData(); + exportWithWriter(new OpmlWriter(), uri); + } - if (requestCode == REQUEST_CODE_CHOOSE_OPML_EXPORT_PATH) { - exportWithWriter(new OpmlWriter(), uri); - } else if (requestCode == REQUEST_CODE_CHOOSE_HTML_EXPORT_PATH) { - exportWithWriter(new HtmlWriter(), uri); - } else if (requestCode == REQUEST_CODE_CHOOSE_FAVORITES_EXPORT_PATH) { - exportWithWriter(new FavoritesWriter(), uri); - } else if (requestCode == REQUEST_CODE_RESTORE_DATABASE) { - progressDialog.show(); - disposable = Completable.fromAction(() -> DatabaseExporter.importBackup(uri, getContext())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - showDatabaseImportSuccessDialog(); - UserPreferences.unsetUsageCountingDate(); - progressDialog.dismiss(); - }, this::showExportErrorDialog); - } else if (requestCode == REQUEST_CODE_BACKUP_DATABASE) { - progressDialog.show(); - disposable = Completable.fromAction(() -> DatabaseExporter.exportToDocument(uri, getContext())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(() -> { - Snackbar.make(getView(), R.string.export_success_title, Snackbar.LENGTH_LONG).show(); - progressDialog.dismiss(); - }, this::showExportErrorDialog); - } else if (requestCode == REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH) { - Intent intent = new Intent(getContext(), OpmlImportActivity.class); - intent.setData(uri); - startActivity(intent); + private void chooseHtmlExportPathResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { + return; } + final Uri uri = result.getData().getData(); + exportWithWriter(new HtmlWriter(), uri); } - private void openExportPathPicker(String contentType, String title, int requestCode, ExportWriter writer) { - if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR2) { - Intent intentPickAction = new Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(contentType) - .putExtra(Intent.EXTRA_TITLE, title); + private void chooseFavoritesExportPathResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { + return; + } + final Uri uri = result.getData().getData(); + exportWithWriter(new FavoritesWriter(), uri); + } - // Creates an implicit intent to launch a file manager which lets - // the user choose a specific directory to export to. - try { - startActivityForResult(intentPickAction, requestCode); - return; - } catch (ActivityNotFoundException e) { - Log.e(TAG, "No activity found. Should never happen..."); - } + private void restoreDatabaseResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK || result.getData() == null) { + return; + } + final Uri uri = result.getData().getData(); + progressDialog.show(); + disposable = Completable.fromAction(() -> DatabaseExporter.importBackup(uri, getContext())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + showDatabaseImportSuccessDialog(); + UserPreferences.unsetUsageCountingDate(); + progressDialog.dismiss(); + }, this::showExportErrorDialog); + } + + private void backupDatabaseResult(final Uri uri) { + if (uri == null) { + return; + } + progressDialog.show(); + disposable = Completable.fromAction(() -> DatabaseExporter.exportToDocument(uri, getContext())) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + Snackbar.make(getView(), R.string.export_success_title, Snackbar.LENGTH_LONG).show(); + progressDialog.dismiss(); + }, this::showExportErrorDialog); + } + + private void chooseOpmlImportPathResult(final Uri uri) { + if (uri == null) { + return; + } + final Intent intent = new Intent(getContext(), OpmlImportActivity.class); + intent.setData(uri); + startActivity(intent); + } + + private void openExportPathPicker(String contentType, String title, + final ActivityResultLauncher<Intent> result, ExportWriter writer) { + Intent intentPickAction = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(contentType) + .putExtra(Intent.EXTRA_TITLE, title); + + // Creates an implicit intent to launch a file manager which lets + // the user choose a specific directory to export to. + try { + result.launch(intentPickAction); + return; + } catch (ActivityNotFoundException e) { + Log.e(TAG, "No activity found. Should never happen..."); } // If we are using a SDK lower than API 21 or the implicit intent failed // fallback to the legacy export process exportWithWriter(writer, null); } + + private static class BackupDatabase extends ActivityResultContracts.CreateDocument { + @NonNull + @Override + public Intent createIntent(@NonNull final Context context, @NonNull final String input) { + return super.createIntent(context, input) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/x-sqlite3"); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java index cc09acbca..891d3737b 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/MainPreferencesFragment.java @@ -1,6 +1,8 @@ package de.danoeh.antennapod.fragment.preferences; import android.content.Intent; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; @@ -17,12 +19,11 @@ import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.fragment.preferences.about.AboutFragment; public class MainPreferencesFragment extends PreferenceFragmentCompat { - private static final String TAG = "MainPreferencesFragment"; private static final String PREF_SCREEN_USER_INTERFACE = "prefScreenInterface"; private static final String PREF_SCREEN_PLAYBACK = "prefScreenPlayback"; private static final String PREF_SCREEN_NETWORK = "prefScreenNetwork"; - private static final String PREF_SCREEN_GPODDER = "prefScreenGpodder"; + private static final String PREF_SCREEN_SYNCHRONIZATION = "prefScreenSynchronization"; private static final String PREF_SCREEN_STORAGE = "prefScreenStorage"; private static final String PREF_DOCUMENTATION = "prefDocumentation"; private static final String PREF_VIEW_FORUM = "prefViewForum"; @@ -43,15 +44,26 @@ public class MainPreferencesFragment extends PreferenceFragmentCompat { // and afterwards remove the following lines. Please keep in mind that AntennaPod is licensed under the GPL. // This means that your application needs to be open-source under the GPL, too. // It must also include a prominent copyright notice. - String packageName = getContext().getPackageName(); - if (!"de.danoeh.antennapod".equals(packageName) && !"de.danoeh.antennapod.debug".equals(packageName)) { + int packageHash = getContext().getPackageName().hashCode(); + if (packageHash != 1790437538 && packageHash != -1190467065) { findPreference(PREF_CATEGORY_PROJECT).setVisible(false); Preference copyrightNotice = new Preference(getContext()); + copyrightNotice.setIcon(R.drawable.ic_info_white); + copyrightNotice.getIcon().mutate() + .setColorFilter(new PorterDuffColorFilter(0xffcc0000, PorterDuff.Mode.MULTIPLY)); copyrightNotice.setSummary("This application is based on AntennaPod." + " The AntennaPod team does NOT provide support for this unofficial version." + " If you can read this message, the developers of this modification" + " violate the GNU General Public License (GPL)."); findPreference(PREF_CATEGORY_PROJECT).getParent().addPreference(copyrightNotice); + } else if (packageHash == -1190467065) { + Preference debugNotice = new Preference(getContext()); + debugNotice.setIcon(R.drawable.ic_info_white); + debugNotice.getIcon().mutate() + .setColorFilter(new PorterDuffColorFilter(0xffcc0000, PorterDuff.Mode.MULTIPLY)); + debugNotice.setOrder(-1); + debugNotice.setSummary("This is a development version of AntennaPod and not meant for daily use"); + findPreference(PREF_CATEGORY_PROJECT).getParent().addPreference(debugNotice); } } @@ -74,8 +86,8 @@ public class MainPreferencesFragment extends PreferenceFragmentCompat { ((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_network); return true; }); - findPreference(PREF_SCREEN_GPODDER).setOnPreferenceClickListener(preference -> { - ((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_gpodder); + findPreference(PREF_SCREEN_SYNCHRONIZATION).setOnPreferenceClickListener(preference -> { + ((PreferenceActivity) getActivity()).openScreen(R.xml.preferences_synchronization); return true; }); findPreference(PREF_SCREEN_STORAGE).setOnPreferenceClickListener(preference -> { @@ -142,8 +154,8 @@ public class MainPreferencesFragment extends PreferenceFragmentCompat { .addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_network)) .addBreadcrumb(R.string.automation) .addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_autodownload)); - config.index(R.xml.preferences_gpodder) - .addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_gpodder)); + config.index(R.xml.preferences_synchronization) + .addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_synchronization)); config.index(R.xml.preferences_notifications) .addBreadcrumb(PreferenceActivity.getTitleOfPage(R.xml.preferences_notifications)); config.index(R.xml.feed_settings) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java index 94e151f7a..ba17cedb2 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/NotificationPreferencesFragment.java @@ -4,11 +4,10 @@ import android.os.Bundle; import androidx.preference.PreferenceFragmentCompat; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.PreferenceActivity; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; public class NotificationPreferencesFragment extends PreferenceFragmentCompat { - private static final String TAG = "NotificationPrefFragment"; private static final String PREF_GPODNET_NOTIFICATIONS = "pref_gpodnet_notifications"; @Override @@ -24,7 +23,6 @@ public class NotificationPreferencesFragment extends PreferenceFragmentCompat { } private void setUpScreen() { - final boolean loggedIn = GpodnetPreferences.loggedIn(); - findPreference(PREF_GPODNET_NOTIFICATIONS).setEnabled(loggedIn); + findPreference(PREF_GPODNET_NOTIFICATIONS).setEnabled(SynchronizationSettings.isProviderConnected()); } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java index 1fa1fed58..7fa2ed4d1 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java @@ -10,13 +10,12 @@ import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.PreferenceActivity; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.preferences.UsageStatistics; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.util.gui.PictureInPictureUtil; import de.danoeh.antennapod.dialog.SkipPreferenceDialog; import de.danoeh.antennapod.dialog.VariableSpeedDialog; -import de.danoeh.antennapod.preferences.PreferenceControllerFlavorHelper; import java.util.Map; import org.greenrobot.eventbus.EventBus; @@ -31,7 +30,6 @@ public class PlaybackPreferencesFragment extends PreferenceFragmentCompat { addPreferencesFromResource(R.xml.preferences_playback); setupPlaybackScreen(); - PreferenceControllerFlavorHelper.setupFlavoredUI(this); buildSmartMarkAsPlayedPreference(); } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackStatisticsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackStatisticsFragment.java index 208ede8cc..ba6164212 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackStatisticsFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackStatisticsFragment.java @@ -28,7 +28,6 @@ import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.StatisticsItem; -import de.danoeh.antennapod.core.util.comparator.CompareCompat; import io.reactivex.Completable; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -68,7 +67,7 @@ public class PlaybackStatisticsFragment extends Fragment { View root = inflater.inflate(R.layout.statistics_activity, container, false); feedStatisticsList = root.findViewById(R.id.statistics_list); progressBar = root.findViewById(R.id.progressBar); - listAdapter = new PlaybackStatisticsListAdapter(getContext()); + listAdapter = new PlaybackStatisticsListAdapter(this); listAdapter.setCountAll(countAll); feedStatisticsList.setLayoutManager(new LinearLayoutManager(getContext())); feedStatisticsList.setAdapter(listAdapter); @@ -188,10 +187,10 @@ public class PlaybackStatisticsFragment extends Fragment { List<StatisticsItem> statisticsData = DBReader.getStatistics(); if (countAll) { Collections.sort(statisticsData, (item1, item2) -> - CompareCompat.compareLong(item1.timePlayedCountAll, item2.timePlayedCountAll)); + Long.compare(item2.timePlayedCountAll, item1.timePlayedCountAll)); } else { Collections.sort(statisticsData, (item1, item2) -> - CompareCompat.compareLong(item1.timePlayed, item2.timePlayed)); + Long.compare(item2.timePlayed, item1.timePlayed)); } return statisticsData; } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/UserInterfacePreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/UserInterfacePreferencesFragment.java index 04b9677e2..ff974179e 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/UserInterfacePreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/UserInterfacePreferencesFragment.java @@ -10,8 +10,8 @@ import androidx.preference.PreferenceFragmentCompat; import android.widget.ListView; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.PreferenceActivity; -import de.danoeh.antennapod.core.event.PlayerStatusEvent; -import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent; +import de.danoeh.antennapod.event.PlayerStatusEvent; +import de.danoeh.antennapod.event.UnreadItemsUpdateEvent; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.dialog.SubscriptionsFilterDialog; import de.danoeh.antennapod.dialog.FeedSortDialog; diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderAuthenticationFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/GpodderAuthenticationFragment.java index c0bf3e0ea..9dfe6840c 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/GpodderAuthenticationFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/GpodderAuthenticationFragment.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.fragment.preferences; +package de.danoeh.antennapod.fragment.preferences.synchronization; import android.app.Dialog; import android.content.Context; @@ -15,30 +15,35 @@ import android.widget.ProgressBar; import android.widget.RadioGroup; import android.widget.TextView; import android.widget.ViewFlipper; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; + import com.google.android.material.button.MaterialButton; import com.google.android.material.textfield.TextInputLayout; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.preferences.GpodnetPreferences; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; import de.danoeh.antennapod.core.sync.SyncService; -import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; import de.danoeh.antennapod.core.util.FileNameGenerator; import de.danoeh.antennapod.core.util.IntentUtils; +import de.danoeh.antennapod.net.sync.gpoddernet.GpodnetService; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; import io.reactivex.Completable; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; -import java.util.List; -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - /** * Guides the user through the authentication process. */ @@ -83,23 +88,24 @@ public class GpodderAuthenticationFragment extends DialogFragment { final RadioGroup serverRadioGroup = view.findViewById(R.id.serverRadioGroup); final EditText serverUrlText = view.findViewById(R.id.serverUrlText); - if (!GpodnetService.DEFAULT_BASE_HOST.equals(GpodnetPreferences.getHosturl())) { - serverUrlText.setText(GpodnetPreferences.getHosturl()); + if (!GpodnetService.DEFAULT_BASE_HOST.equals(SynchronizationCredentials.getHosturl())) { + serverUrlText.setText(SynchronizationCredentials.getHosturl()); } final TextInputLayout serverUrlTextInput = view.findViewById(R.id.serverUrlTextInput); serverRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { serverUrlTextInput.setVisibility(checkedId == R.id.customServerRadio ? View.VISIBLE : View.GONE); }); selectHost.setOnClickListener(v -> { + SynchronizationCredentials.clear(getContext()); if (serverRadioGroup.getCheckedRadioButtonId() == R.id.customServerRadio) { - GpodnetPreferences.setHosturl(serverUrlText.getText().toString()); + SynchronizationCredentials.setHosturl(serverUrlText.getText().toString()); } else { - GpodnetPreferences.setHosturl(GpodnetService.DEFAULT_BASE_HOST); + SynchronizationCredentials.setHosturl(GpodnetService.DEFAULT_BASE_HOST); } service = new GpodnetService(AntennapodHttpClient.getHttpClient(), - GpodnetPreferences.getHosturl(), GpodnetPreferences.getDeviceID(), - GpodnetPreferences.getUsername(), GpodnetPreferences.getPassword()); - getDialog().setTitle(GpodnetPreferences.getHosturl()); + SynchronizationCredentials.getHosturl(), SynchronizationCredentials.getDeviceID(), + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getPassword()); + getDialog().setTitle(SynchronizationCredentials.getHosturl()); advance(); }); } @@ -116,7 +122,7 @@ public class GpodderAuthenticationFragment extends DialogFragment { createAccount.setPaintFlags(createAccount.getPaintFlags() | Paint.UNDERLINE_TEXT_FLAG); createAccount.setOnClickListener(v -> IntentUtils.openInBrowser(getContext(), "https://gpodder.net/register/")); - if (GpodnetPreferences.getHosturl().startsWith("http://")) { + if (SynchronizationCredentials.getHosturl().startsWith("http://")) { createAccountWarning.setVisibility(View.VISIBLE); } password.setOnEditorActionListener((v, actionID, event) -> @@ -265,15 +271,8 @@ public class GpodderAuthenticationFragment extends DialogFragment { }); } - private void writeLoginCredentials() { - GpodnetPreferences.setUsername(username); - GpodnetPreferences.setPassword(password); - GpodnetPreferences.setDeviceID(selectedDevice.getId()); - } - private void advance() { if (currentStep < STEP_FINISH) { - View view = viewFlipper.getChildAt(currentStep + 1); if (currentStep == STEP_DEFAULT) { setupHostView(view); @@ -289,7 +288,10 @@ public class GpodderAuthenticationFragment extends DialogFragment { if (selectedDevice == null) { throw new IllegalStateException("Device must not be null here"); } else { - writeLoginCredentials(); + SynchronizationSettings.setSelectedSyncProvider(SynchronizationProviderViewData.GPODDER_NET); + SynchronizationCredentials.setUsername(username); + SynchronizationCredentials.setPassword(password); + SynchronizationCredentials.setDeviceID(selectedDevice.getId()); setupFinishView(view); } } diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java new file mode 100644 index 000000000..2e9260c1d --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/NextcloudAuthenticationFragment.java @@ -0,0 +1,92 @@ +package de.danoeh.antennapod.fragment.preferences.synchronization; + +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; +import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; +import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; +import de.danoeh.antennapod.databinding.NextcloudAuthDialogBinding; +import de.danoeh.antennapod.net.sync.nextcloud.NextcloudLoginFlow; + +/** + * Guides the user through the authentication process. + */ +public class NextcloudAuthenticationFragment extends DialogFragment + implements NextcloudLoginFlow.AuthenticationCallback { + public static final String TAG = "NextcloudAuthenticationFragment"; + private NextcloudAuthDialogBinding viewBinding; + private NextcloudLoginFlow nextcloudLoginFlow; + private boolean shouldDismiss = false; + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + AlertDialog.Builder dialog = new AlertDialog.Builder(getContext()); + dialog.setTitle(R.string.gpodnetauth_login_butLabel); + dialog.setNegativeButton(R.string.cancel_label, null); + dialog.setCancelable(false); + this.setCancelable(false); + + viewBinding = NextcloudAuthDialogBinding.inflate(getLayoutInflater()); + dialog.setView(viewBinding.getRoot()); + + viewBinding.loginButton.setOnClickListener(v -> { + viewBinding.errorText.setVisibility(View.GONE); + viewBinding.loginButton.setVisibility(View.GONE); + viewBinding.loginProgressContainer.setVisibility(View.VISIBLE); + nextcloudLoginFlow = new NextcloudLoginFlow(AntennapodHttpClient.getHttpClient(), + viewBinding.serverUrlText.getText().toString(), getContext(), this); + nextcloudLoginFlow.start(); + }); + + return dialog.create(); + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + if (nextcloudLoginFlow != null) { + nextcloudLoginFlow.cancel(); + } + } + + @Override + public void onResume() { + super.onResume(); + if (shouldDismiss) { + dismiss(); + } + } + + @Override + public void onNextcloudAuthenticated(String server, String username, String password) { + SynchronizationSettings.setSelectedSyncProvider(SynchronizationProviderViewData.NEXTCLOUD_GPODDER); + SynchronizationCredentials.clear(getContext()); + SynchronizationCredentials.setPassword(password); + SynchronizationCredentials.setHosturl(server); + SynchronizationCredentials.setUsername(username); + SyncService.fullSync(getContext()); + if (isVisible()) { + dismiss(); + } else { + shouldDismiss = true; + } + } + + @Override + public void onNextcloudAuthError(String errorMessage) { + viewBinding.loginProgressContainer.setVisibility(View.GONE); + viewBinding.errorText.setVisibility(View.VISIBLE); + viewBinding.errorText.setText(errorMessage); + viewBinding.loginButton.setVisibility(View.VISIBLE); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java new file mode 100644 index 000000000..8cb7f45db --- /dev/null +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/synchronization/SynchronizationPreferencesFragment.java @@ -0,0 +1,222 @@ +package de.danoeh.antennapod.fragment.preferences.synchronization; + +import android.app.Activity; +import android.os.Bundle; +import android.text.Spanned; +import android.text.format.DateUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ImageView; +import android.widget.ListAdapter; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.text.HtmlCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import com.google.android.material.snackbar.Snackbar; + +import org.greenrobot.eventbus.EventBus; +import org.greenrobot.eventbus.Subscribe; +import org.greenrobot.eventbus.ThreadMode; + +import de.danoeh.antennapod.R; +import de.danoeh.antennapod.activity.PreferenceActivity; +import de.danoeh.antennapod.event.SyncServiceEvent; +import de.danoeh.antennapod.core.sync.SynchronizationCredentials; +import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.SynchronizationProviderViewData; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; +import de.danoeh.antennapod.dialog.AuthenticationDialog; + +public class SynchronizationPreferencesFragment extends PreferenceFragmentCompat { + private static final String PREFERENCE_SYNCHRONIZATION_DESCRIPTION = "preference_synchronization_description"; + private static final String PREFERENCE_GPODNET_SETLOGIN_INFORMATION = "pref_gpodnet_setlogin_information"; + private static final String PREFERENCE_SYNC = "pref_synchronization_sync"; + private static final String PREFERENCE_FORCE_FULL_SYNC = "pref_synchronization_force_full_sync"; + private static final String PREFERENCE_LOGOUT = "pref_synchronization_logout"; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.preferences_synchronization); + setupScreen(); + updateScreen(); + } + + @Override + public void onStart() { + super.onStart(); + ((PreferenceActivity) getActivity()).getSupportActionBar().setTitle(R.string.synchronization_pref); + updateScreen(); + EventBus.getDefault().register(this); + } + + @Override + public void onStop() { + super.onStop(); + EventBus.getDefault().unregister(this); + ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(""); + } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + public void syncStatusChanged(SyncServiceEvent event) { + if (!SynchronizationSettings.isProviderConnected()) { + return; + } + updateScreen(); + if (event.getMessageResId() == R.string.sync_status_error + || event.getMessageResId() == R.string.sync_status_success) { + updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful(), + SynchronizationSettings.getLastSyncAttempt()); + } else { + ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(event.getMessageResId()); + } + } + + private void setupScreen() { + final Activity activity = getActivity(); + findPreference(PREFERENCE_GPODNET_SETLOGIN_INFORMATION) + .setOnPreferenceClickListener(preference -> { + AuthenticationDialog dialog = new AuthenticationDialog(activity, + R.string.pref_gpodnet_setlogin_information_title, + false, SynchronizationCredentials.getUsername(), null) { + @Override + protected void onConfirmed(String username, String password) { + SynchronizationCredentials.setPassword(password); + } + }; + dialog.show(); + return true; + }); + findPreference(PREFERENCE_SYNC).setOnPreferenceClickListener(preference -> { + SyncService.syncImmediately(getActivity().getApplicationContext()); + return true; + }); + findPreference(PREFERENCE_FORCE_FULL_SYNC).setOnPreferenceClickListener(preference -> { + SyncService.fullSync(getContext()); + return true; + }); + findPreference(PREFERENCE_LOGOUT).setOnPreferenceClickListener(preference -> { + SynchronizationCredentials.clear(getContext()); + Snackbar.make(getView(), R.string.pref_synchronization_logout_toast, Snackbar.LENGTH_LONG).show(); + SynchronizationSettings.setSelectedSyncProvider(null); + updateScreen(); + return true; + }); + } + + private void updateScreen() { + final boolean loggedIn = SynchronizationSettings.isProviderConnected(); + Preference preferenceHeader = findPreference(PREFERENCE_SYNCHRONIZATION_DESCRIPTION); + if (loggedIn) { + SynchronizationProviderViewData selectedProvider = + SynchronizationProviderViewData.fromIdentifier(getSelectedSyncProviderKey()); + preferenceHeader.setTitle(""); + preferenceHeader.setSummary(selectedProvider.getSummaryResource()); + preferenceHeader.setIcon(selectedProvider.getIconResource()); + preferenceHeader.setOnPreferenceClickListener(null); + } else { + preferenceHeader.setTitle(R.string.synchronization_choose_title); + preferenceHeader.setSummary(R.string.synchronization_summary_unchoosen); + preferenceHeader.setIcon(R.drawable.ic_cloud); + preferenceHeader.setOnPreferenceClickListener((preference) -> { + chooseProviderAndLogin(); + return true; + }); + } + + Preference gpodnetSetLoginPreference = findPreference(PREFERENCE_GPODNET_SETLOGIN_INFORMATION); + gpodnetSetLoginPreference.setVisible(isProviderSelected(SynchronizationProviderViewData.GPODDER_NET)); + gpodnetSetLoginPreference.setEnabled(loggedIn); + findPreference(PREFERENCE_SYNC).setEnabled(loggedIn); + findPreference(PREFERENCE_FORCE_FULL_SYNC).setEnabled(loggedIn); + findPreference(PREFERENCE_LOGOUT).setEnabled(loggedIn); + if (loggedIn) { + String summary = getString(R.string.synchronization_login_status, + SynchronizationCredentials.getUsername(), SynchronizationCredentials.getHosturl()); + Spanned formattedSummary = HtmlCompat.fromHtml(summary, HtmlCompat.FROM_HTML_MODE_LEGACY); + findPreference(PREFERENCE_LOGOUT).setSummary(formattedSummary); + updateLastSyncReport(SynchronizationSettings.isLastSyncSuccessful(), + SynchronizationSettings.getLastSyncAttempt()); + } else { + findPreference(PREFERENCE_LOGOUT).setSummary(null); + ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(null); + } + } + + private void chooseProviderAndLogin() { + AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setTitle(R.string.dialog_choose_sync_service_title); + + SynchronizationProviderViewData[] providers = SynchronizationProviderViewData.values(); + ListAdapter adapter = new ArrayAdapter<SynchronizationProviderViewData>( + getContext(), R.layout.alertdialog_sync_provider_chooser, providers) { + + ViewHolder holder; + + class ViewHolder { + ImageView icon; + TextView title; + } + + public View getView(int position, View convertView, ViewGroup parent) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + if (convertView == null) { + convertView = inflater.inflate( + R.layout.alertdialog_sync_provider_chooser, null); + + holder = new ViewHolder(); + holder.icon = (ImageView) convertView.findViewById(R.id.icon); + holder.title = (TextView) convertView.findViewById(R.id.title); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + SynchronizationProviderViewData synchronizationProviderViewData = getItem(position); + holder.title.setText(synchronizationProviderViewData.getSummaryResource()); + holder.icon.setImageResource(synchronizationProviderViewData.getIconResource()); + return convertView; + } + }; + + builder.setAdapter(adapter, (dialog, which) -> { + switch (providers[which]) { + case GPODDER_NET: + new GpodderAuthenticationFragment() + .show(getChildFragmentManager(), GpodderAuthenticationFragment.TAG); + break; + case NEXTCLOUD_GPODDER: + new NextcloudAuthenticationFragment() + .show(getChildFragmentManager(), NextcloudAuthenticationFragment.TAG); + break; + default: + break; + } + updateScreen(); + }); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private boolean isProviderSelected(@NonNull SynchronizationProviderViewData provider) { + String selectedSyncProviderKey = getSelectedSyncProviderKey(); + return provider.getIdentifier().equals(selectedSyncProviderKey); + } + + private String getSelectedSyncProviderKey() { + return SynchronizationSettings.getSelectedSyncProviderKey(); + } + + private void updateLastSyncReport(boolean successful, long lastTime) { + String status = String.format("%1$s (%2$s)", getString(successful + ? R.string.gpodnetsync_pref_report_successful : R.string.gpodnetsync_pref_report_failed), + DateUtils.getRelativeDateTimeString(getContext(), + lastTime, DateUtils.MINUTE_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, DateUtils.FORMAT_SHOW_TIME)); + ((PreferenceActivity) getActivity()).getSupportActionBar().setSubtitle(status); + } +} diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/swipeactions/SwipeActions.java b/app/src/main/java/de/danoeh/antennapod/fragment/swipeactions/SwipeActions.java index 50c7c1ae5..adf133856 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/swipeactions/SwipeActions.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/swipeactions/SwipeActions.java @@ -201,12 +201,12 @@ public class SwipeActions extends ItemTouchHelper.SimpleCallback implements Life @Override public float getSwipeEscapeVelocity(float defaultValue) { - return swipeOutEnabled ? defaultValue : Float.MAX_VALUE; + return swipeOutEnabled ? defaultValue * 1.5f : Float.MAX_VALUE; } @Override public float getSwipeVelocityThreshold(float defaultValue) { - return swipeOutEnabled ? defaultValue : 0; + return swipeOutEnabled ? defaultValue * 0.6f : 0; } @Override diff --git a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java index c272af7d5..23fdb86de 100644 --- a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java +++ b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java @@ -13,12 +13,12 @@ import com.google.android.material.snackbar.Snackbar; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; -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.playback.PlaybackService; import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.core.sync.SyncService; +import de.danoeh.antennapod.core.sync.SynchronizationSettings; +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.ShareUtils; @@ -151,7 +151,7 @@ public class FeedItemMenuHandler { } else if (menuItemId == R.id.mark_read_item) { selectedItem.setPlayed(true); DBWriter.markItemPlayed(selectedItem, FeedItem.PLAYED, true); - if (GpodnetPreferences.loggedIn()) { + if (SynchronizationSettings.isProviderConnected()) { FeedMedia media = selectedItem.getMedia(); // not all items have media, Gpodder only cares about those that do if (media != null) { @@ -161,17 +161,17 @@ public class FeedItemMenuHandler { .position(media.getDuration() / 1000) .total(media.getDuration() / 1000) .build(); - SyncService.enqueueEpisodeAction(context, actionPlay); + SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, actionPlay); } } } else if (menuItemId == R.id.mark_unread_item) { selectedItem.setPlayed(false); DBWriter.markItemPlayed(selectedItem, FeedItem.UNPLAYED, false); - if (GpodnetPreferences.loggedIn() && selectedItem.getMedia() != null) { + if (selectedItem.getMedia() != null) { EpisodeAction actionNew = new EpisodeAction.Builder(selectedItem, EpisodeAction.NEW) .currentTimestamp() .build(); - SyncService.enqueueEpisodeAction(context, actionNew); + SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, actionNew); } } else if (menuItemId == R.id.add_to_queue_item) { DBWriter.addQueueItem(context, selectedItem); diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java index 84c738632..af35bbac9 100644 --- a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java +++ b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java @@ -108,9 +108,12 @@ public class PreferenceUpgrader { } } if (oldVersion < 2040000) { - SharedPreferences prefs = context.getSharedPreferences(SwipeActions.PREF_NAME, Context.MODE_PRIVATE); - prefs.edit().putString(SwipeActions.KEY_PREFIX_SWIPEACTIONS + QueueFragment.TAG, + SharedPreferences swipePrefs = context.getSharedPreferences(SwipeActions.PREF_NAME, Context.MODE_PRIVATE); + swipePrefs.edit().putString(SwipeActions.KEY_PREFIX_SWIPEACTIONS + QueueFragment.TAG, SwipeAction.REMOVE_FROM_QUEUE + "," + SwipeAction.REMOVE_FROM_QUEUE).apply(); } + if (oldVersion < 2050000) { + prefs.edit().putBoolean(UserPreferences.PREF_PAUSE_PLAYBACK_FOR_FOCUS_LOSS, true).apply(); + } } } diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java index 1075117dd..020f4374b 100644 --- a/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java +++ b/app/src/main/java/de/danoeh/antennapod/receiver/ConnectivityActionReceiver.java @@ -4,37 +4,23 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.net.ConnectivityManager; -import android.net.NetworkInfo; import android.text.TextUtils; import android.util.Log; + import de.danoeh.antennapod.core.ClientConfig; -import de.danoeh.antennapod.core.storage.DBTasks; -import de.danoeh.antennapod.core.storage.DownloadRequester; import de.danoeh.antennapod.core.util.NetworkUtils; public class ConnectivityActionReceiver extends BroadcastReceiver { - private static final String TAG = "ConnectivityActionRecvr"; + private static final String TAG = "ConnectivityActionRecvr"; - @Override - public void onReceive(final Context context, Intent intent) { - if (TextUtils.equals(intent.getAction(), ConnectivityManager.CONNECTIVITY_ACTION)) { - Log.d(TAG, "Received intent"); + @Override + public void onReceive(final Context context, Intent intent) { + if (TextUtils.equals(intent.getAction(), ConnectivityManager.CONNECTIVITY_ACTION)) { + Log.d(TAG, "Received intent"); ClientConfig.initialize(context); - if (NetworkUtils.autodownloadNetworkAvailable()) { - Log.d(TAG, "auto-dl network available, starting auto-download"); - DBTasks.autodownloadUndownloadedItems(context); - } else { // if new network is Wi-Fi, finish ongoing downloads, - // otherwise cancel all downloads - ConnectivityManager cm = (ConnectivityManager) context - .getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo ni = cm.getActiveNetworkInfo(); - if (ni == null || ni.getType() != ConnectivityManager.TYPE_WIFI) { - Log.i(TAG, "Device is no longer connected to Wi-Fi. Cancelling ongoing downloads"); - DownloadRequester.getInstance().cancelAllDownloads(context); - } - } - } - } + NetworkUtils.networkChangedDetected(); + } + } } diff --git a/app/src/main/java/de/danoeh/antennapod/view/PlaybackSpeedSeekBar.java b/app/src/main/java/de/danoeh/antennapod/view/PlaybackSpeedSeekBar.java index c75164a74..33f0d47b8 100644 --- a/app/src/main/java/de/danoeh/antennapod/view/PlaybackSpeedSeekBar.java +++ b/app/src/main/java/de/danoeh/antennapod/view/PlaybackSpeedSeekBar.java @@ -9,11 +9,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.util.Consumer; import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.util.playback.PlaybackController; public class PlaybackSpeedSeekBar extends FrameLayout { private SeekBar seekBar; - private PlaybackController controller; private Consumer<Float> progressChangedListener; public PlaybackSpeedSeekBar(@NonNull Context context) { @@ -40,15 +38,9 @@ public class PlaybackSpeedSeekBar extends FrameLayout { seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (controller != null) { - float playbackSpeed = (progress + 10) / 20.0f; - controller.setPlaybackSpeed(playbackSpeed); - - if (progressChangedListener != null) { - progressChangedListener.accept(playbackSpeed); - } - } else if (fromUser) { - seekBar.post(() -> updateSpeed()); + float playbackSpeed = (progress + 10) / 20.0f; + if (progressChangedListener != null) { + progressChangedListener.accept(playbackSpeed); } } @@ -62,21 +54,23 @@ public class PlaybackSpeedSeekBar extends FrameLayout { }); } - public void updateSpeed() { - if (controller != null) { - seekBar.setProgress(Math.round((20 * controller.getCurrentPlaybackSpeedMultiplier()) - 10)); - } - } - - public void setController(PlaybackController controller) { - this.controller = controller; - updateSpeed(); - if (progressChangedListener != null && controller != null) { - progressChangedListener.accept(controller.getCurrentPlaybackSpeedMultiplier()); - } + public void updateSpeed(float speedMultiplier) { + seekBar.setProgress(Math.round((20 * speedMultiplier) - 10)); } public void setProgressChangedListener(Consumer<Float> progressChangedListener) { this.progressChangedListener = progressChangedListener; } + + public float getCurrentSpeed() { + return (seekBar.getProgress() + 10) / 20.0f; + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + seekBar.setEnabled(enabled); + findViewById(R.id.butDecSpeed).setEnabled(enabled); + findViewById(R.id.butIncSpeed).setEnabled(enabled); + } } diff --git a/app/src/main/java/de/danoeh/antennapod/view/ToolbarIconTintManager.java b/app/src/main/java/de/danoeh/antennapod/view/ToolbarIconTintManager.java index 37d8db03e..621b6ea95 100644 --- a/app/src/main/java/de/danoeh/antennapod/view/ToolbarIconTintManager.java +++ b/app/src/main/java/de/danoeh/antennapod/view/ToolbarIconTintManager.java @@ -6,7 +6,6 @@ import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.view.ContextThemeWrapper; import androidx.appcompat.widget.Toolbar; -import androidx.core.view.ViewCompat; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.CollapsingToolbarLayout; import de.danoeh.antennapod.R; @@ -25,7 +24,7 @@ public abstract class ToolbarIconTintManager implements AppBarLayout.OnOffsetCha @Override public void onOffsetChanged(AppBarLayout appBarLayout, int offset) { - boolean tint = (collapsingToolbar.getHeight() + offset) > (2 * ViewCompat.getMinimumHeight(collapsingToolbar)); + boolean tint = (collapsingToolbar.getHeight() + offset) > (2 * collapsingToolbar.getMinimumHeight()); if (isTinted != tint) { isTinted = tint; updateTint(); diff --git a/app/src/main/java/de/danoeh/antennapod/view/viewholder/EpisodeItemViewHolder.java b/app/src/main/java/de/danoeh/antennapod/view/viewholder/EpisodeItemViewHolder.java index cd3af5003..8d1810ecb 100644 --- a/app/src/main/java/de/danoeh/antennapod/view/viewholder/EpisodeItemViewHolder.java +++ b/app/src/main/java/de/danoeh/antennapod/view/viewholder/EpisodeItemViewHolder.java @@ -21,7 +21,7 @@ import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MainActivity; import de.danoeh.antennapod.adapter.CoverLoader; import de.danoeh.antennapod.adapter.actionbutton.ItemActionButton; -import de.danoeh.antennapod.core.event.PlaybackPositionEvent; +import de.danoeh.antennapod.event.playback.PlaybackPositionEvent; import de.danoeh.antennapod.core.util.DateFormatter; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedMedia; diff --git a/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml b/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml new file mode 100644 index 000000000..9b4d62804 --- /dev/null +++ b/app/src/main/res/layout/alertdialog_sync_provider_chooser.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="fill_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:padding="16dp"> + + <ImageView + android:id="@+id/icon" + android:layout_width="48dp" + android:layout_height="48dp" + android:layout_marginRight="16dip" + android:layout_marginEnd="16dip" + android:layout_gravity="center_vertical" /> + + <TextView + android:id="@+id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="" + android:layout_gravity="center" /> + +</LinearLayout> diff --git a/app/src/main/res/layout/audioplayer_fragment.xml b/app/src/main/res/layout/audioplayer_fragment.xml index f801930f5..7efbd23c8 100644 --- a/app/src/main/res/layout/audioplayer_fragment.xml +++ b/app/src/main/res/layout/audioplayer_fragment.xml @@ -12,6 +12,7 @@ android:minHeight="?attr/actionBarSize" android:theme="?attr/actionBarTheme" android:layout_alignParentTop="true" + app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" android:id="@+id/toolbar"/> diff --git a/app/src/main/res/layout/edit_tags_dialog.xml b/app/src/main/res/layout/edit_tags_dialog.xml index 57e3c412f..9ac0b60d3 100644 --- a/app/src/main/res/layout/edit_tags_dialog.xml +++ b/app/src/main/res/layout/edit_tags_dialog.xml @@ -7,6 +7,16 @@ android:orientation="vertical" android:padding="16dp"> + <com.joanzapata.iconify.widget.IconTextView + android:id="@+id/commonTagsInfo" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:visibility="gone" + android:textSize="@dimen/text_size_micro" + android:paddingBottom="16dp" + android:text="@string/multi_feed_common_tags_info" /> + <androidx.recyclerview.widget.RecyclerView android:id="@+id/tagsRecycler" android:layout_width="match_parent" diff --git a/app/src/main/res/layout/episode_filter_dialog.xml b/app/src/main/res/layout/episode_filter_dialog.xml index 9661a8e72..e8672c2f3 100644 --- a/app/src/main/res/layout/episode_filter_dialog.xml +++ b/app/src/main/res/layout/episode_filter_dialog.xml @@ -40,4 +40,21 @@ android:minLines="1" android:scrollbars="vertical" /> + <CheckBox + android:id="@+id/checkbox_filter_duration" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/episode_filters_duration" /> + + <EditText + android:id="@+id/etxtEpisodeFilterDurationText" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:cursorVisible="true" + android:focusable="true" + android:focusableInTouchMode="true" + android:inputType="numberSigned" + android:lines="1" /> + </LinearLayout> diff --git a/app/src/main/res/layout/feed_statistics.xml b/app/src/main/res/layout/feed_statistics.xml new file mode 100644 index 000000000..f8f5ac555 --- /dev/null +++ b/app/src/main/res/layout/feed_statistics.xml @@ -0,0 +1,113 @@ +<?xml version="1.0" encoding="utf-8"?> +<TableLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TableRow + android:tag="detailed"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_episodes_started_total" /> + + <TextView + android:id="@+id/startedTotalLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0 / 0" /> + + </TableRow> + + <TableRow> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_time_played" /> + + <TextView + android:id="@+id/timePlayedLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0 min" /> + + </TableRow> + + <TableRow + android:tag="detailed"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_duration_played_episodes" /> + + <TextView + android:id="@+id/durationPlayedLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0 min" /> + + </TableRow> + + <TableRow + android:tag="detailed"> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_total_duration" /> + + <TextView + android:id="@+id/totalDurationLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0 min" /> + + </TableRow> + + <TableRow> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_episodes_on_device" /> + + <TextView + android:id="@+id/onDeviceLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0" /> + + </TableRow> + + <TableRow> + + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/statistics_space_used" /> + + <TextView + android:id="@+id/spaceUsedLabel" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginLeft="8dp" + android:layout_marginStart="8dp" + tools:text="0 MB" /> + + </TableRow> + +</TableLayout> diff --git a/app/src/main/res/layout/feed_statistics_dialog.xml b/app/src/main/res/layout/feed_statistics_dialog.xml new file mode 100644 index 000000000..fcd36fe7a --- /dev/null +++ b/app/src/main/res/layout/feed_statistics_dialog.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.fragment.app.FragmentContainerView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/statisticsContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp" /> diff --git a/app/src/main/res/layout/feedinfo.xml b/app/src/main/res/layout/feedinfo.xml index d753cbda1..b0a73cb97 100644 --- a/app/src/main/res/layout/feedinfo.xml +++ b/app/src/main/res/layout/feedinfo.xml @@ -1,210 +1,164 @@ <?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout - xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" - xmlns:tools="http://schemas.android.com/tools" - android:layout_width="match_parent" - android:layout_height="match_parent"> + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout - android:id="@+id/appBar" - android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:id="@+id/appBar" + android:layout_width="match_parent" + android:layout_height="wrap_content"> <com.google.android.material.appbar.CollapsingToolbarLayout - android:id="@+id/collapsing_toolbar" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="?android:attr/windowBackground" - app:contentScrim="?android:attr/windowBackground" - app:layout_scrollFlags="scroll|exitUntilCollapsed" - app:scrimAnimationDuration="200"> + android:id="@+id/collapsing_toolbar" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?android:attr/windowBackground" + app:contentScrim="?android:attr/windowBackground" + app:layout_scrollFlags="scroll|exitUntilCollapsed" + app:scrimAnimationDuration="200"> <ImageView - android:id="@+id/imgvBackground" - style="@style/BigBlurryBackground" - android:layout_width="match_parent" - android:layout_height="232dp" - android:background="@color/image_readability_tint" - app:layout_collapseMode="parallax" - app:layout_collapseParallaxMultiplier="0.6" /> + android:id="@+id/imgvBackground" + android:layout_width="match_parent" + android:layout_height="232dp" + android:background="@color/image_readability_tint" + style="@style/BigBlurryBackground" + app:layout_collapseMode="parallax" + app:layout_collapseParallaxMultiplier="0.6" /> <include - layout="@layout/feeditemlist_header" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_gravity="bottom" - app:layout_collapseMode="parallax" - app:layout_collapseParallaxMultiplier="0.6" /> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + layout="@layout/feeditemlist_header" + app:layout_collapseMode="parallax" + app:layout_collapseParallaxMultiplier="0.6" /> <androidx.appcompat.widget.Toolbar - android:id="@+id/toolbar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_alignParentTop="true" - android:minHeight="?attr/actionBarSize" - android:theme="?attr/actionBarTheme" - app:layout_collapseMode="pin" - app:navigationIcon="?homeAsUpIndicator" /> + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:minHeight="?attr/actionBarSize" + android:theme="?attr/actionBarTheme" + app:layout_collapseMode="pin" + app:navigationContentDescription="@string/toolbar_back_button_content_description" + app:navigationIcon="?homeAsUpIndicator" /> </com.google.android.material.appbar.CollapsingToolbarLayout> + </com.google.android.material.appbar.AppBarLayout> <androidx.core.widget.NestedScrollView - android:id="@+id/scrollView" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:clipToPadding="false" - android:paddingLeft="16dp" - android:paddingRight="16dp" - android:paddingBottom="8dp" - android:scrollbarStyle="outsideOverlay" - app:layout_behavior="@string/appbar_scrolling_view_behavior"> + android:id="@+id/scrollView" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingLeft="16dp" + android:paddingRight="16dp" + android:paddingBottom="8dp" + android:scrollbarStyle="outsideOverlay" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> <LinearLayout - android:id="@+id/infoContainer" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:paddingHorizontal="@dimen/additional_horizontal_spacing"> + android:id="@+id/infoContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingHorizontal="@dimen/additional_horizontal_spacing"> <TextView - android:id="@+id/lblUrl" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="18sp" - android:layout_marginTop="16dp" - android:layout_marginBottom="4dp" - android:text="@string/url_label" - android:textColor="?android:attr/textColorPrimary" - tools:background="@android:color/holo_red_light" /> + android:id="@+id/lblUrl" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="18sp" + android:layout_marginTop="16dp" + android:layout_marginBottom="4dp" + android:text="@string/url_label" + android:textColor="?android:attr/textColorPrimary" + tools:background="@android:color/holo_red_light" /> <TextView - android:id="@+id/txtvUrl" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="?attr/selectableItemBackground" - android:maxLines="4" - android:paddingTop="4dp" - android:paddingBottom="4dp" - tools:background="@android:color/holo_green_dark" - tools:text="http://www.example.com/feed" /> + android:id="@+id/txtvUrl" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="?attr/selectableItemBackground" + android:maxLines="4" + android:paddingTop="4dp" + android:paddingBottom="4dp" + tools:background="@android:color/holo_green_dark" + tools:text="http://www.example.com/feed" /> <TextView - android:id="@+id/lblSupport" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:layout_marginBottom="4dp" - android:text="@string/support_funding_label" - android:textColor="?android:attr/textColorPrimary" - android:textSize="18sp" - tools:background="@android:color/holo_red_light" /> + android:id="@+id/lblSupport" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginBottom="4dp" + android:text="@string/support_funding_label" + android:textColor="?android:attr/textColorPrimary" + android:textSize="18sp" + tools:background="@android:color/holo_red_light" /> <TextView - android:id="@+id/txtvFundingUrl" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:maxLines="8" - android:paddingTop="4dp" - android:paddingBottom="4dp" - android:linksClickable="true" - android:autoLink="web" - tools:background="@android:color/holo_green_dark" /> + android:id="@+id/txtvFundingUrl" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:maxLines="8" + android:paddingTop="4dp" + android:paddingBottom="4dp" + android:linksClickable="true" + android:autoLink="web" + tools:background="@android:color/holo_green_dark" /> <TextView - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textSize="18sp" - android:layout_marginTop="16dp" - android:layout_marginBottom="4dp" - android:text="@string/description_label" - android:textColor="?android:attr/textColorPrimary" - tools:background="@android:color/holo_red_light" /> + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="18sp" + android:layout_marginTop="16dp" + android:layout_marginBottom="4dp" + android:text="@string/description_label" + android:textColor="?android:attr/textColorPrimary" + tools:background="@android:color/holo_red_light" /> <TextView - android:id="@+id/txtvDescription" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/design_time_lorem_ipsum" - android:textIsSelectable="true" - tools:background="@android:color/holo_green_dark" /> + android:id="@+id/txtvDescription" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/design_time_lorem_ipsum" + android:textIsSelectable="true" + tools:background="@android:color/holo_green_dark" /> <TextView - android:id="@+id/lblStatistics" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:textSize="18sp" - android:layout_marginBottom="8dp" - android:text="@string/statistics_label" - android:textColor="?android:attr/textColorPrimary" - tools:background="@android:color/holo_red_light" /> - - <TableLayout - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <TableRow - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/statistics_listened_for" /> - - <TextView - android:id="@+id/txtvPodcastTime" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginLeft="5dp" /> - </TableRow> - - <TableRow - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/statistics_episodes_on_device" /> - - <TextView - android:id="@+id/txtvPodcastEpisodeCount" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="5dp" /> - </TableRow> - - <TableRow - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <TextView - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/statistics_space_used" /> - - <TextView - android:id="@+id/txtvPodcastSpaceUsed" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginLeft="5dp" /> - </TableRow> - - </TableLayout> + android:id="@+id/lblStatistics" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:textSize="18sp" + android:layout_marginBottom="8dp" + android:text="@string/statistics_label" + android:textColor="?android:attr/textColorPrimary" + tools:background="@android:color/holo_red_light" /> + + <androidx.fragment.app.FragmentContainerView + android:id="@+id/statisticsFragmentContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> <Button - android:id="@+id/btnvOpenStatistics" - style="@style/Widget.MaterialComponents.Button.TextButton" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:minWidth="0dp" - android:minHeight="0dp" - android:text="@string/statistics_view_all" /> + android:id="@+id/btnvOpenStatistics" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:minWidth="0dp" + android:minHeight="0dp" + android:text="@string/statistics_view_all" + style="@style/Widget.MaterialComponents.Button.TextButton" /> </LinearLayout> + </androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/app/src/main/res/layout/feeditem_pager_fragment.xml b/app/src/main/res/layout/feeditem_pager_fragment.xml index ac7316dd8..690ac3bc1 100644 --- a/app/src/main/res/layout/feeditem_pager_fragment.xml +++ b/app/src/main/res/layout/feeditem_pager_fragment.xml @@ -11,6 +11,7 @@ android:layout_height="wrap_content" android:minHeight="?attr/actionBarSize" android:theme="?attr/actionBarTheme" + app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" android:id="@+id/toolbar"/> diff --git a/app/src/main/res/layout/feedsettings.xml b/app/src/main/res/layout/feedsettings.xml index acd1089bd..df6e666eb 100644 --- a/app/src/main/res/layout/feedsettings.xml +++ b/app/src/main/res/layout/feedsettings.xml @@ -11,6 +11,7 @@ android:minHeight="?attr/actionBarSize" android:theme="?attr/actionBarTheme" app:title="@string/feed_settings_label" + app:navigationContentDescription="@string/toolbar_back_button_content_description" app:navigationIcon="?homeAsUpIndicator" android:elevation="4dp" android:id="@+id/toolbar"/> diff --git a/app/src/main/res/layout/fragment_subscriptions.xml b/app/src/main/res/layout/fragment_subscriptions.xml index 6dd112eed..5672a310f 100644 --- a/app/src/main/res/layout/fragment_subscriptions.xml +++ b/app/src/main/res/layout/fragment_subscriptions.xml @@ -2,6 +2,7 @@ <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> @@ -40,9 +41,11 @@ android:id="@+id/subscriptions_grid" android:layout_width="match_parent" android:layout_height="match_parent" + android:clipToPadding="false" android:layout_gravity="center_horizontal" android:paddingBottom="88dp" - android:clipToPadding="false" /> + tools:itemCount="2" + tools:listitem="@layout/subscription_item" /> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> diff --git a/app/src/main/res/layout/nextcloud_auth_dialog.xml b/app/src/main/res/layout/nextcloud_auth_dialog.xml new file mode 100644 index 000000000..345eec88b --- /dev/null +++ b/app/src/main/res/layout/nextcloud_auth_dialog.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="16dp" + android:orientation="vertical" + android:clipToPadding="false"> + + <com.google.android.material.textfield.TextInputLayout + android:id="@+id/serverUrlTextInput" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="16dp" + style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"> + + <com.google.android.material.textfield.TextInputEditText + android:id="@+id/serverUrlText" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="@string/gpodnetauth_host" + android:inputType="textNoSuggestions" + android:lines="1" + android:imeOptions="actionNext|flagNoFullscreen" /> + + </com.google.android.material.textfield.TextInputLayout> + + <LinearLayout + android:id="@+id/loginProgressContainer" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:orientation="horizontal" + android:layout_gravity="center_vertical"> + + <ProgressBar + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:layout_marginRight="8dp" /> + + <TextView + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="@string/synchronization_nextcloud_authenticate_browser" /> + + </LinearLayout> + + <TextView + android:id="@+id/errorText" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:visibility="gone" + android:textColor="@color/download_failed_red" + android:layout_marginBottom="16dp" /> + + <Button + android:id="@+id/loginButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/gpodnetauth_login_butLabel" /> + +</LinearLayout> diff --git a/app/src/main/res/layout/playback_speed_feed_setting_dialog.xml b/app/src/main/res/layout/playback_speed_feed_setting_dialog.xml new file mode 100644 index 000000000..572096911 --- /dev/null +++ b/app/src/main/res/layout/playback_speed_feed_setting_dialog.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="16dp"> + + <CheckBox + android:id="@+id/useGlobalCheckbox" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/feed_auto_download_global" + android:layout_marginBottom="8dp" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> + + <de.danoeh.antennapod.view.PlaybackSpeedSeekBar + android:id="@+id/seekBar" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" /> + + <TextView + android:id="@+id/currentSpeedLabel" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginLeft="8dp" /> + + </LinearLayout> + +</LinearLayout> diff --git a/app/src/main/res/layout/playback_speed_seek_bar.xml b/app/src/main/res/layout/playback_speed_seek_bar.xml index 8c9b1725f..155a2261a 100644 --- a/app/src/main/res/layout/playback_speed_seek_bar.xml +++ b/app/src/main/res/layout/playback_speed_seek_bar.xml @@ -14,6 +14,7 @@ android:text="-" android:clickable="true" android:focusable="true" + android:scrollbars="none" android:textStyle="bold" android:textSize="24sp" android:textColor="?attr/colorSecondary" @@ -36,6 +37,7 @@ android:text="+" android:clickable="true" android:focusable="true" + android:scrollbars="none" android:textStyle="bold" android:textSize="24sp" android:textColor="?attr/colorSecondary" diff --git a/app/src/main/res/layout/quick_feed_discovery_item.xml b/app/src/main/res/layout/quick_feed_discovery_item.xml index cb03b6677..c3a32f019 100644 --- a/app/src/main/res/layout/quick_feed_discovery_item.xml +++ b/app/src/main/res/layout/quick_feed_discovery_item.xml @@ -2,6 +2,7 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:squareImageView="http://schemas.android.com/apk/de.danoeh.antennapod" + xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="4dp" @@ -14,7 +15,8 @@ android:elevation="4dp" android:outlineProvider="bounds" android:foreground="?android:attr/selectableItemBackground" - squareImageView:direction="width" /> + squareImageView:direction="width" + tools:src="@android:drawable/sym_def_app_icon"/> </LinearLayout> diff --git a/app/src/main/res/layout/subscription_selection_activity.xml b/app/src/main/res/layout/subscription_selection_activity.xml new file mode 100644 index 000000000..b54e7e4a4 --- /dev/null +++ b/app/src/main/res/layout/subscription_selection_activity.xml @@ -0,0 +1,58 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/transparentBackground" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.cardview.widget.CardView + android:id="@+id/card" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="32dp" + android:elevation="16dp" + app:cardCornerRadius="4dp"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?attr/actionBarSize" + android:theme="?attr/actionBarTheme" + android:layout_alignParentTop="true" /> + + <View + android:id="@+id/divider" + android:layout_width="match_parent" + android:layout_height="1dp" + android:layout_below="@id/toolbar" + android:background="?android:attr/listDivider" /> + + <ListView + android:id="@+id/list" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:clipToPadding="false" + android:layout_below="@id/divider" + android:paddingBottom="88dp" /> + + <Button + android:id="@+id/shortcutBtn" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:layout_alignParentBottom="true" + android:text="@string/add_shortcut" /> + + </RelativeLayout> + + </androidx.cardview.widget.CardView> + +</LinearLayout> diff --git a/app/src/main/res/layout/time_dialog.xml b/app/src/main/res/layout/time_dialog.xml index 6b6ab3195..138a60b33 100644 --- a/app/src/main/res/layout/time_dialog.xml +++ b/app/src/main/res/layout/time_dialog.xml @@ -1,136 +1,144 @@ <?xml version="1.0" encoding="utf-8"?> -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - xmlns:tools="http://schemas.android.com/tools" - android:orientation="vertical" - android:gravity="center" - android:padding="16dp"> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:gravity="center" + android:padding="16dp"> <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:id="@+id/timeSetup" - android:orientation="vertical"> + android:id="@+id/timeSetup" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal"> <EditText - android:id="@+id/etxtTime" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" - android:layout_margin="8dp" - android:ems="2" - android:inputType="number" - android:maxLength="3"/> + android:id="@+id/etxtTime" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:layout_margin="8dp" + android:ems="2" + android:inputType="number" + android:maxLength="3" /> <Spinner - android:id="@+id/spTimeUnit" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="8dp" - android:layout_marginTop="8dp"/> + android:id="@+id/spTimeUnit" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="8dp" + android:layout_marginTop="8dp" /> + </LinearLayout> <Button - android:text="@string/set_sleeptimer_label" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:id="@+id/setSleeptimerButton"/> + android:id="@+id/setSleeptimerButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/set_sleeptimer_label" /> + </LinearLayout> <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:id="@+id/timeDisplay" - android:orientation="vertical" - android:visibility="gone"> + android:id="@+id/timeDisplay" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:visibility="visible"> <TextView - android:text="00:00:00" - android:layout_gravity="center" - android:gravity="center" - android:textSize="32sp" - android:textColor="?android:attr/textColorPrimary" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:id="@+id/time"/> + android:id="@+id/time" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:text="00:00:00" + android:layout_gravity="center" + android:gravity="center" + android:textSize="32sp" + android:textColor="?android:attr/textColorPrimary" /> <Button - android:text="@string/disable_sleeptimer_label" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:id="@+id/disableSleeptimerButton"/> + android:id="@+id/disableSleeptimerButton" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/disable_sleeptimer_label" /> <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="horizontal"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> <Button - android:id="@+id/extendSleepFiveMinutesButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginEnd="4dp" - android:layout_marginRight="4dp" - android:layout_weight="1" - android:padding="5dp" - tools:text="+5 min" /> + android:id="@+id/extendSleepFiveMinutesButton" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="4dp" + android:layout_marginRight="4dp" + android:paddingHorizontal="2dp" + android:paddingVertical="4dp" + android:layout_weight="1" + style="?attr/materialButtonOutlinedStyle" + tools:text="+5 min" /> <Button - android:id="@+id/extendSleepTenMinutesButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginEnd="4dp" - android:layout_weight="1" - tools:text="+10 min" /> + android:id="@+id/extendSleepTenMinutesButton" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:layout_marginEnd="4dp" + android:paddingHorizontal="2dp" + android:paddingVertical="4dp" + android:layout_weight="1" + style="?attr/materialButtonOutlinedStyle" + tools:text="+10 min" /> <Button - android:id="@+id/extendSleepTwentyMinutesButton" - style="?attr/materialButtonOutlinedStyle" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginStart="4dp" - android:layout_marginRight="4dp" - android:layout_marginLeft="4dp" - android:layout_weight="1" - tools:text="+20 min" /> + android:id="@+id/extendSleepTwentyMinutesButton" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:layout_marginRight="4dp" + android:layout_marginLeft="4dp" + android:paddingHorizontal="2dp" + android:paddingVertical="4dp" + android:layout_weight="1" + style="?attr/materialButtonOutlinedStyle" + tools:text="+20 min" /> </LinearLayout> </LinearLayout> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:layout_marginTop="8dp"> + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:layout_marginTop="8dp"> <CheckBox - android:id="@+id/cbShakeToReset" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/shake_to_reset_label"/> + android:id="@+id/cbShakeToReset" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/shake_to_reset_label" /> <CheckBox - android:id="@+id/cbVibrate" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/timer_vibration_label"/> + android:id="@+id/cbVibrate" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/timer_vibration_label" /> <CheckBox - android:id="@+id/chAutoEnable" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/auto_enable_label"/> + android:id="@+id/chAutoEnable" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/auto_enable_label" /> </LinearLayout> diff --git a/app/src/main/res/menu/cast_enabled.xml b/app/src/main/res/menu/cast_enabled.xml deleted file mode 100644 index d6e85c311..000000000 --- a/app/src/main/res/menu/cast_enabled.xml +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<menu xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:custom="http://schemas.android.com/apk/res-auto"> - - <item - android:id="@+id/media_route_menu_item" - android:title="@string/cast_media_route_menu_title" - custom:actionProviderClass="de.danoeh.antennapod.core.cast.SwitchableMediaRouteActionProvider" - custom:showAsAction="ifRoom"/> -</menu>
\ No newline at end of file diff --git a/app/src/main/res/menu/nav_feed_action_speeddial.xml b/app/src/main/res/menu/nav_feed_action_speeddial.xml index 2dfa002bb..d08aa645f 100644 --- a/app/src/main/res/menu/nav_feed_action_speeddial.xml +++ b/app/src/main/res/menu/nav_feed_action_speeddial.xml @@ -25,4 +25,9 @@ android:menuCategory="container" android:title="@string/playback_speed" android:icon="@drawable/ic_playback_speed"/> + <item + android:id="@+id/edit_tags" + android:menuCategory="container" + android:title="@string/edit_tags" + android:icon="@drawable/ic_tag"/> </menu> diff --git a/app/src/main/res/menu/nav_feed_context.xml b/app/src/main/res/menu/nav_feed_context.xml index 17c15cbb0..3f5127f36 100644 --- a/app/src/main/res/menu/nav_feed_context.xml +++ b/app/src/main/res/menu/nav_feed_context.xml @@ -7,9 +7,9 @@ android:title="@string/remove_all_new_flags_label" /> <item - android:id="@+id/add_to_folder" + android:id="@+id/edit_tags" android:menuCategory="container" - android:title="@string/add_to_folder" /> + android:title="@string/edit_tags" /> <item android:id="@+id/rename_item" diff --git a/app/src/main/res/menu/nav_folder_context.xml b/app/src/main/res/menu/nav_folder_context.xml new file mode 100644 index 000000000..eb6515bed --- /dev/null +++ b/app/src/main/res/menu/nav_folder_context.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android"> + <item + android:id="@+id/rename_folder_item" + android:menuCategory="container" + android:title="@string/rename_tag_label" /> +</menu>
\ No newline at end of file diff --git a/app/src/main/res/xml/feed_settings.xml b/app/src/main/res/xml/feed_settings.xml index 457ff6e5b..007f084c9 100644 --- a/app/src/main/res/xml/feed_settings.xml +++ b/app/src/main/res/xml/feed_settings.xml @@ -1,72 +1,71 @@ <?xml version="1.0" encoding="utf-8"?> -<PreferenceScreen - xmlns:android="http://schemas.android.com/apk/res/android" +<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" android:key="feedSettingsScreen"> <SwitchPreferenceCompat - android:key="keepUpdated" - android:icon="@drawable/ic_refresh" - android:title="@string/keep_updated" - android:summary="@string/keep_updated_summary"/> + android:icon="@drawable/ic_refresh" + android:key="keepUpdated" + android:summary="@string/keep_updated_summary" + android:title="@string/keep_updated" /> <SwitchPreferenceCompat - android:key="episodeNotification" - android:defaultValue="false" - android:dependency="keepUpdated" - android:icon="@drawable/ic_notifications" - android:title="@string/episode_notification" - android:summary="@string/episode_notification_summary"/> + android:defaultValue="false" + android:dependency="keepUpdated" + android:icon="@drawable/ic_notifications" + android:key="episodeNotification" + android:summary="@string/episode_notification_summary" + android:title="@string/episode_notification" /> <Preference - android:key="authentication" - android:icon="@drawable/ic_key" - android:title="@string/authentication_label" - android:summary="@string/authentication_descr"/> + android:icon="@drawable/ic_key" + android:key="authentication" + android:summary="@string/authentication_descr" + android:title="@string/authentication_label" /> <Preference - android:key="tags" - android:icon="@drawable/ic_folder" - android:title="@string/feed_folders_label" - android:summary="@string/feed_folders_summary"/> + android:icon="@drawable/ic_tag" + android:key="tags" + android:summary="@string/feed_tags_summary" + android:title="@string/feed_tags_label" /> - <ListPreference - android:key="feedPlaybackSpeed" - android:icon="@drawable/ic_playback_speed" - android:title="@string/playback_speed" - android:summary="@string/pref_feed_playback_speed_sum"/> + <Preference + android:icon="@drawable/ic_playback_speed" + android:key="feedPlaybackSpeed" + android:summary="@string/pref_feed_playback_speed_sum" + android:title="@string/playback_speed" /> <Preference - android:key="feedAutoSkip" - android:icon="@drawable/ic_skip_24dp" - android:summary="@string/pref_feed_skip_sum" - android:title="@string/pref_feed_skip" /> + android:icon="@drawable/ic_skip_24dp" + android:key="feedAutoSkip" + android:summary="@string/pref_feed_skip_sum" + android:title="@string/pref_feed_skip" /> <ListPreference - android:entries="@array/spnAutoDeleteItems" - android:entryValues="@array/spnAutoDeleteValues" - android:icon="@drawable/ic_delete" - android:title="@string/auto_delete_label" - android:summary="@string/feed_auto_download_global" - android:key="autoDelete"/> + android:entries="@array/spnAutoDeleteItems" + android:entryValues="@array/spnAutoDeleteValues" + android:icon="@drawable/ic_delete" + android:key="autoDelete" + android:summary="@string/feed_auto_download_global" + android:title="@string/auto_delete_label" /> <ListPreference - android:entries="@array/spnVolumeReductionItems" - android:entryValues="@array/spnVolumeReductionValues" - android:icon="@drawable/ic_volume_adaption" - android:summary="@string/feed_volume_reduction_summary" - android:title="@string/feed_volume_reduction" - android:defaultValue="off" - android:key="volumeReduction"/> + android:defaultValue="off" + android:entries="@array/spnVolumeReductionItems" + android:entryValues="@array/spnVolumeReductionValues" + android:icon="@drawable/ic_volume_adaption" + android:key="volumeReduction" + android:summary="@string/feed_volume_reduction_summary" + android:title="@string/feed_volume_reduction" /> <PreferenceCategory - android:title="@string/auto_download_settings_label" - android:key="autoDownloadCategory"> + android:key="autoDownloadCategory" + android:title="@string/auto_download_settings_label"> <SwitchPreferenceCompat - android:key="autoDownload" - android:title="@string/auto_download_label"/> + android:key="autoDownload" + android:title="@string/auto_download_label" /> <Preference - android:key="episodeFilter" - android:title="@string/episode_filters_label" - android:summary="@string/episode_filters_description"/> + android:key="episodeFilter" + android:summary="@string/episode_filters_description" + android:title="@string/episode_filters_label" /> </PreferenceCategory> </PreferenceScreen> diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..d4c3fc996 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<network-security-config xmlns:tools="http://schemas.android.com/tools"> + <base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration"> + <trust-anchors> + <certificates src="user" tools:ignore="AcceptsUserCertificates"/> + <certificates src="system" /> + </trust-anchors> + </base-config> +</network-security-config> diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index d528945c7..7c5012899 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -28,7 +28,7 @@ android:icon="@drawable/ic_network" /> <Preference - android:key="prefScreenGpodder" + android:key="prefScreenSynchronization" android:title="@string/synchronization_pref" android:summary="@string/synchronization_sum" android:icon="@drawable/ic_cloud" /> diff --git a/app/src/main/res/xml/preferences_gpodder.xml b/app/src/main/res/xml/preferences_gpodder.xml deleted file mode 100644 index a210b8e11..000000000 --- a/app/src/main/res/xml/preferences_gpodder.xml +++ /dev/null @@ -1,28 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<PreferenceScreen - xmlns:android="http://schemas.android.com/apk/res/android"> - <Preference - android:key="pref_gpodnet_description" - android:icon="@drawable/gpodder_icon" - android:summary="@string/gpodnet_description"/> - <Preference - android:key="pref_gpodnet_authenticate" - android:title="@string/pref_gpodnet_authenticate_title" - android:summary="@string/pref_gpodnet_authenticate_sum"/> - <Preference - android:key="pref_gpodnet_setlogin_information" - android:title="@string/pref_gpodnet_setlogin_information_title" - android:summary="@string/pref_gpodnet_setlogin_information_sum"/> - <Preference - android:key="pref_gpodnet_sync" - android:title="@string/pref_gpodnet_sync_changes_title" - android:summary="@string/pref_gpodnet_sync_changes_sum"/> - <Preference - android:key="pref_gpodnet_force_full_sync" - android:title="@string/pref_gpodnet_full_sync_title" - android:summary="@string/pref_gpodnet_full_sync_sum"/> - <Preference - android:key="pref_gpodnet_logout" - android:title="@string/pref_gpodnet_logout_title"/> - -</PreferenceScreen> diff --git a/app/src/main/res/xml/preferences_playback.xml b/app/src/main/res/xml/preferences_playback.xml index 2be8492eb..add9e8d4c 100644 --- a/app/src/main/res/xml/preferences_playback.xml +++ b/app/src/main/res/xml/preferences_playback.xml @@ -23,7 +23,7 @@ android:summary="@string/pref_unpauseOnBluetoothReconnect_sum" android:title="@string/pref_unpauseOnBluetoothReconnect_title"/> <SwitchPreferenceCompat - android:defaultValue="false" + android:defaultValue="true" android:enabled="true" android:key="prefPauseForFocusLoss" android:summary="@string/pref_pausePlaybackForFocusLoss_sum" @@ -127,11 +127,5 @@ android:title="@string/media_player" android:summary="@string/pref_media_player_message" android:entryValues="@array/media_player_values"/> - <SwitchPreferenceCompat - android:defaultValue="false" - android:enabled="true" - android:key="prefCast" - android:summary="@string/pref_cast_message" - android:title="@string/pref_cast_title"/> </PreferenceCategory> </PreferenceScreen> diff --git a/app/src/main/res/xml/preferences_synchronization.xml b/app/src/main/res/xml/preferences_synchronization.xml new file mode 100644 index 000000000..fbd4ccc79 --- /dev/null +++ b/app/src/main/res/xml/preferences_synchronization.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<PreferenceScreen + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <Preference + android:key="preference_synchronization_description" + android:icon="@drawable/ic_notification_sync" + android:summary="@string/synchronization_summary_unchoosen"/> + + <Preference + android:key="pref_gpodnet_setlogin_information" + android:title="@string/pref_gpodnet_setlogin_information_title" + android:summary="@string/pref_gpodnet_setlogin_information_sum" + app:isPreferenceVisible="false"/> + + <Preference + android:key="pref_synchronization_sync" + android:title="@string/synchronization_sync_changes_title" + android:summary="@string/synchronization_sync_summary"/> + + <Preference + android:key="pref_synchronization_force_full_sync" + android:title="@string/synchronization_full_sync_title" + android:summary="@string/synchronization_force_sync_summary"/> + + <Preference + android:key="pref_synchronization_logout" + android:title="@string/synchronization_logout"/> + +</PreferenceScreen> diff --git a/app/src/main/res/xml/preferences_user_interface.xml b/app/src/main/res/xml/preferences_user_interface.xml index 0b2707a18..59e7092a1 100644 --- a/app/src/main/res/xml/preferences_user_interface.xml +++ b/app/src/main/res/xml/preferences_user_interface.xml @@ -44,6 +44,11 @@ android:title="@string/pref_filter_feed_title" android:key="prefSubscriptionsFilter" android:summary="@string/pref_filter_feed_sum" /> + <SwitchPreferenceCompat + android:title="@string/pref_show_subscription_title" + android:key="prefSubscriptionTitle" + android:summary="@string/pref_show_subscription_title_summary" + android:defaultValue="false" /> </PreferenceCategory> <PreferenceCategory android:title="@string/external_elements"> <SwitchPreferenceCompat diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml index a16b679e3..045996714 100644 --- a/app/src/main/res/xml/provider_paths.xml +++ b/app/src/main/res/xml/provider_paths.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <paths> <external-path name="external_storage" path="."/> - <root-path name="external_files" path="/storage/" /> + <files-path name="name" path="." /> </paths> diff --git a/app/src/play/java/de/danoeh/antennapod/activity/CastEnabledActivity.java b/app/src/play/java/de/danoeh/antennapod/activity/CastEnabledActivity.java deleted file mode 100644 index 753feb3e7..000000000 --- a/app/src/play/java/de/danoeh/antennapod/activity/CastEnabledActivity.java +++ /dev/null @@ -1,157 +0,0 @@ -package de.danoeh.antennapod.activity; - -import android.content.SharedPreferences; -import android.media.AudioManager; -import android.os.Bundle; -import androidx.preference.PreferenceManager; -import androidx.appcompat.app.AppCompatActivity; -import android.util.Log; -import android.view.Menu; -import android.view.MenuItem; - -import com.google.android.gms.cast.ApplicationMetadata; - -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.cast.CastButtonVisibilityManager; -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.cast.SwitchableMediaRouteActionProvider; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.service.playback.PlaybackService; - -import java.util.ArrayList; -import java.util.List; - -/** - * Activity that allows for showing the MediaRouter button whenever there's a cast device in the - * network. - */ -public abstract class CastEnabledActivity extends AppCompatActivity - implements SharedPreferences.OnSharedPreferenceChangeListener { - public static final String TAG = "CastEnabledActivity"; - - private CastConsumer castConsumer; - private CastManager castManager; - private final List<CastButtonVisibilityManager> castButtons = new ArrayList<>(); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (!CastManager.isInitialized()) { - return; - } - - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .registerOnSharedPreferenceChangeListener(this); - - castConsumer = new DefaultCastConsumer() { - @Override - public void onApplicationConnected(ApplicationMetadata appMetadata, String sessionId, boolean wasLaunched) { - onCastConnectionChanged(true); - } - - @Override - public void onDisconnected() { - onCastConnectionChanged(false); - } - }; - castManager = CastManager.getInstance(); - castManager.addCastConsumer(castConsumer); - CastButtonVisibilityManager castButtonVisibilityManager = new CastButtonVisibilityManager(castManager); - castButtonVisibilityManager.setPrefEnabled(UserPreferences.isCastEnabled()); - onCastConnectionChanged(castManager.isConnected()); - castButtons.add(castButtonVisibilityManager); - } - - @Override - protected void onDestroy() { - if (!CastManager.isInitialized()) { - super.onDestroy(); - return; - } - PreferenceManager.getDefaultSharedPreferences(getApplicationContext()) - .unregisterOnSharedPreferenceChangeListener(this); - castManager.removeCastConsumer(castConsumer); - super.onDestroy(); - } - - @Override - protected void onResume() { - super.onResume(); - if (!CastManager.isInitialized()) { - return; - } - for (CastButtonVisibilityManager castButton : castButtons) { - castButton.setResumed(true); - } - } - - @Override - protected void onPause() { - super.onPause(); - if (!CastManager.isInitialized()) { - return; - } - for (CastButtonVisibilityManager castButton : castButtons) { - castButton.setResumed(false); - } - } - - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (UserPreferences.PREF_CAST_ENABLED.equals(key)) { - boolean newValue = UserPreferences.isCastEnabled(); - Log.d(TAG, "onSharedPreferenceChanged(), isCastEnabled set to " + newValue); - for (CastButtonVisibilityManager castButton : castButtons) { - castButton.setPrefEnabled(newValue); - } - // PlaybackService has its own listener, so if it's active we don't have to take action here. - if (!newValue && !PlaybackService.isRunning) { - CastManager.getInstance().disconnect(); - } - } - } - - private void onCastConnectionChanged(boolean connected) { - if (connected) { - for (CastButtonVisibilityManager castButton : castButtons) { - castButton.onConnected(); - } - setVolumeControlStream(AudioManager.USE_DEFAULT_STREAM_TYPE); - } else { - for (CastButtonVisibilityManager castButton : castButtons) { - castButton.onDisconnected(); - } - setVolumeControlStream(AudioManager.STREAM_MUSIC); - } - } - - /** - * Should be called by any activity or fragment for which the cast button should be shown. - */ - public final void requestCastButton(Menu menu) { - if (!CastManager.isInitialized()) { - return; - } - - MenuItem mediaRouteButton = menu.findItem(R.id.media_route_menu_item); - if (mediaRouteButton == null) { - getMenuInflater().inflate(R.menu.cast_enabled, menu); - mediaRouteButton = menu.findItem(R.id.media_route_menu_item); - } - - SwitchableMediaRouteActionProvider mediaRouteActionProvider = - CastManager.getInstance().addMediaRouterButton(mediaRouteButton); - CastButtonVisibilityManager castButtonVisibilityManager = - new CastButtonVisibilityManager(CastManager.getInstance()); - castButtonVisibilityManager.setMenu(menu); - castButtonVisibilityManager.setPrefEnabled(UserPreferences.isCastEnabled()); - castButtonVisibilityManager.mediaRouteActionProvider = mediaRouteActionProvider; - castButtonVisibilityManager.setResumed(true); - castButtonVisibilityManager.requestCastButton(MenuItem.SHOW_AS_ACTION_ALWAYS); - mediaRouteActionProvider.setEnabled(castButtonVisibilityManager.shouldEnable()); - } -} diff --git a/app/src/play/java/de/danoeh/antennapod/config/CastCallbackImpl.java b/app/src/play/java/de/danoeh/antennapod/config/CastCallbackImpl.java deleted file mode 100644 index 2a879c62d..000000000 --- a/app/src/play/java/de/danoeh/antennapod/config/CastCallbackImpl.java +++ /dev/null @@ -1,21 +0,0 @@ -package de.danoeh.antennapod.config; - -import androidx.annotation.NonNull; -import androidx.mediarouter.app.MediaRouteControllerDialogFragment; -import androidx.mediarouter.app.MediaRouteDialogFactory; - -import de.danoeh.antennapod.core.CastCallbacks; -import de.danoeh.antennapod.fragment.CustomMRControllerDialogFragment; - -public class CastCallbackImpl implements CastCallbacks { - @Override - public MediaRouteDialogFactory getMediaRouterDialogFactory() { - return new MediaRouteDialogFactory() { - @NonNull - @Override - public MediaRouteControllerDialogFragment onCreateControllerDialogFragment() { - return new CustomMRControllerDialogFragment(); - } - }; - } -} diff --git a/app/src/play/java/de/danoeh/antennapod/dialog/CustomMRControllerDialog.java b/app/src/play/java/de/danoeh/antennapod/dialog/CustomMRControllerDialog.java deleted file mode 100644 index 6d8450a18..000000000 --- a/app/src/play/java/de/danoeh/antennapod/dialog/CustomMRControllerDialog.java +++ /dev/null @@ -1,480 +0,0 @@ -package de.danoeh.antennapod.dialog; - -import android.app.PendingIntent; -import android.content.Context; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.os.Bundle; -import android.os.RemoteException; -import androidx.annotation.NonNull; -import android.support.v4.media.MediaDescriptionCompat; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import androidx.core.util.Pair; -import androidx.core.view.MarginLayoutParamsCompat; -import androidx.core.view.accessibility.AccessibilityEventCompat; -import androidx.mediarouter.app.MediaRouteControllerDialog; -import androidx.palette.graphics.Palette; -import androidx.mediarouter.media.MediaRouter; -import androidx.appcompat.widget.AppCompatImageView; -import android.text.TextUtils; -import android.util.Log; -import android.util.TypedValue; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityManager; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; -import com.bumptech.glide.request.target.Target; - -import java.util.concurrent.ExecutionException; - -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.glide.ApGlideSettings; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; - -public class CustomMRControllerDialog extends MediaRouteControllerDialog { - public static final String TAG = "CustomMRContrDialog"; - - private MediaRouter mediaRouter; - private MediaSessionCompat.Token token; - - private ImageView artView; - private TextView titleView; - private TextView subtitleView; - private ImageButton playPauseButton; - private LinearLayout rootView; - - private boolean viewsCreated = false; - - private Disposable fetchArtSubscription; - - private MediaControllerCompat mediaController; - private MediaControllerCompat.Callback mediaControllerCallback; - - public CustomMRControllerDialog(Context context) { - this(context, 0); - } - - private CustomMRControllerDialog(Context context, int theme) { - super(context, theme); - mediaRouter = MediaRouter.getInstance(getContext()); - token = mediaRouter.getMediaSessionToken(); - try { - if (token != null) { - mediaController = new MediaControllerCompat(getContext(), token); - } - } catch (RemoteException e) { - Log.e(TAG, "Error creating media controller", e); - } - - if (mediaController != null) { - mediaControllerCallback = new MediaControllerCompat.Callback() { - @Override - public void onSessionDestroyed() { - if (mediaController != null) { - mediaController.unregisterCallback(mediaControllerCallback); - mediaController = null; - } - } - - @Override - public void onMetadataChanged(MediaMetadataCompat metadata) { - updateViews(); - } - - @Override - public void onPlaybackStateChanged(PlaybackStateCompat state) { - updateState(); - } - }; - mediaController.registerCallback(mediaControllerCallback); - } - } - - @Override - public View onCreateMediaControlView(Bundle savedInstanceState) { - boolean landscape = getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; - if (landscape) { - /* - * When a horizontal LinearLayout measures itself, it first measures its children and - * settles their widths on the first pass, and only then figures out its height, never - * revisiting the widths measurements. - * When one has a child view that imposes a certain aspect ratio (such as an ImageView), - * then its width and height are related to each other, and so if one allows for a large - * height, then it will request for itself a large width as well. However, on the first - * child measurement, the LinearLayout imposes a very relaxed height bound, that the - * child uses to tell the width it wants, a value which the LinearLayout will interpret - * as final, even though the child will want to change it once a more restrictive height - * bound is imposed later. - * - * Our solution is, given that the heights of the children do not depend on their widths - * in this case, we first figure out the layout's height and only then perform the - * usual sequence of measurements. - * - * Note: this solution does not take into account any vertical paddings nor children's - * vertical margins in determining the height, as this View as well as its children are - * defined in code and no paddings/margins that would influence these computations are - * introduced. - * - * There were no resources online for this type of issue as far as I could gather. - */ - rootView = new LinearLayout(getContext()) { - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - // We'd like to find the overall height before adjusting the widths within the LinearLayout - int maxHeight = Integer.MIN_VALUE; - if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) { - for (int i = 0; i < getChildCount(); i++) { - int height = Integer.MIN_VALUE; - View child = getChildAt(i); - ViewGroup.LayoutParams lp = child.getLayoutParams(); - // we only measure children whose layout_height is not MATCH_PARENT - if (lp.height >= 0) { - height = lp.height; - } else if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) { - child.measure(widthMeasureSpec, heightMeasureSpec); - height = child.getMeasuredHeight(); - } - maxHeight = Math.max(maxHeight, height); - } - } - if (maxHeight > 0) { - super.onMeasure(widthMeasureSpec, - MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.EXACTLY)); - } else { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - } - }; - rootView.setOrientation(LinearLayout.HORIZONTAL); - } else { - rootView = new LinearLayout(getContext()); - rootView.setOrientation(LinearLayout.VERTICAL); - } - FrameLayout.LayoutParams rootParams = new FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT); - rootParams.setMargins(0, 0, 0, - getContext().getResources().getDimensionPixelSize(R.dimen.media_router_controller_bottom_margin)); - rootView.setLayoutParams(rootParams); - - // Start the session activity when a content item (album art, title or subtitle) is clicked. - View.OnClickListener onClickListener = v -> { - if (mediaController != null) { - PendingIntent pi = mediaController.getSessionActivity(); - if (pi != null) { - try { - pi.send(); - dismiss(); - } catch (PendingIntent.CanceledException e) { - Log.e(TAG, pi + " was not sent, it had been canceled."); - } - } - } - }; - - LinearLayout.LayoutParams artParams; - /* - * On portrait orientation, we want to limit the artView's height to 9/16 of the available - * width. Reason is that we need to choose the height wisely otherwise we risk the dialog - * being much larger than the screen, and there doesn't seem to be a good way to know the - * available height beforehand. - * - * On landscape orientation, we want to limit the artView's width to its available height. - * Otherwise, horizontal images would take too much space and severely restrict the space - * for episode title and play/pause button. - * - * Internal implementation of ImageView only uses the source image's aspect ratio, but we - * want to impose our own and fallback to the source image's when it is more favorable. - * Solutions were inspired, among other similar sources, on - * http://stackoverflow.com/questions/18077325/scale-image-to-fill-imageview-width-and-keep-aspect-ratio - */ - if (landscape) { - artView = new AppCompatImageView(getContext()) { - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int desiredWidth = widthMeasureSpec; - int desiredMeasureMode = MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? - MeasureSpec.EXACTLY : MeasureSpec.AT_MOST; - if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) { - Drawable drawable = getDrawable(); - if (drawable != null) { - int intrHeight = drawable.getIntrinsicHeight(); - int intrWidth = drawable.getIntrinsicWidth(); - int originalHeight = MeasureSpec.getSize(heightMeasureSpec); - if (intrHeight < intrWidth) { - desiredWidth = MeasureSpec.makeMeasureSpec( - originalHeight, desiredMeasureMode); - } else { - desiredWidth = MeasureSpec.makeMeasureSpec( - Math.round((float) originalHeight * intrWidth / intrHeight), - desiredMeasureMode); - } - } - } - super.onMeasure(desiredWidth, heightMeasureSpec); - } - }; - artParams = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT); - MarginLayoutParamsCompat.setMarginStart(artParams, - getContext().getResources().getDimensionPixelSize(R.dimen.media_router_controller_playback_control_horizontal_spacing)); - } else { - artView = new AppCompatImageView(getContext()) { - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - int desiredHeight = heightMeasureSpec; - if (MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY) { - Drawable drawable = getDrawable(); - if (drawable != null) { - int originalWidth = MeasureSpec.getSize(widthMeasureSpec); - int intrHeight = drawable.getIntrinsicHeight(); - int intrWidth = drawable.getIntrinsicWidth(); - float scale; - if (intrHeight*16 > intrWidth*9) { - // image is taller than 16:9 - scale = (float) originalWidth * 9 / 16 / intrHeight; - } else { - // image is more horizontal than 16:9 - scale = (float) originalWidth / intrWidth; - } - desiredHeight = MeasureSpec.makeMeasureSpec( - Math.round(intrHeight * scale), - MeasureSpec.EXACTLY); - } - } - super.onMeasure(widthMeasureSpec, desiredHeight); - } - }; - artParams = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT); - } - // When we fetch the bitmap, we want to know if we should set a background color or not. - artView.setTag(landscape); - - artView.setScaleType(ImageView.ScaleType.FIT_CENTER); - artView.setOnClickListener(onClickListener); - - artView.setLayoutParams(artParams); - rootView.addView(artView); - - ViewGroup wrapper = rootView; - - if (landscape) { - // Here we wrap with a frame layout because we want to set different layout parameters - // for landscape orientation. - wrapper = new FrameLayout(getContext()); - wrapper.setLayoutParams(new LinearLayout.LayoutParams( - 0, - ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); - rootView.addView(wrapper); - rootView.setWeightSum(1f); - } - - View playbackControlLayout = View.inflate(getContext(), R.layout.media_router_controller, wrapper); - - titleView = playbackControlLayout.findViewById(R.id.mrc_control_title); - subtitleView = playbackControlLayout.findViewById(R.id.mrc_control_subtitle); - playbackControlLayout.findViewById(R.id.mrc_control_title_container).setOnClickListener(onClickListener); - playPauseButton = playbackControlLayout.findViewById(R.id.mrc_control_play_pause); - playPauseButton.setOnClickListener(v -> { - PlaybackStateCompat state; - if (mediaController != null && (state = mediaController.getPlaybackState()) != null) { - boolean isPlaying = state.getState() == PlaybackStateCompat.STATE_PLAYING; - if (isPlaying) { - mediaController.getTransportControls().pause(); - } else { - mediaController.getTransportControls().play(); - } - // Announce the action for accessibility. - AccessibilityManager accessibilityManager = (AccessibilityManager) - getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); - if (accessibilityManager != null && accessibilityManager.isEnabled()) { - AccessibilityEvent event = AccessibilityEvent.obtain( - AccessibilityEventCompat.TYPE_ANNOUNCEMENT); - event.setPackageName(getContext().getPackageName()); - event.setClassName(getClass().getName()); - int resId = isPlaying ? R.string.mr_controller_pause : R.string.mr_controller_play; - event.getText().add(getContext().getString(resId)); - accessibilityManager.sendAccessibilityEvent(event); - } - } - }); - - viewsCreated = true; - updateViews(); - return rootView; - } - - @Override - public void onDetachedFromWindow() { - if (fetchArtSubscription != null) { - fetchArtSubscription.dispose(); - fetchArtSubscription = null; - } - super.onDetachedFromWindow(); - } - - private void updateViews() { - if (!viewsCreated || token == null || mediaController == null) { - rootView.setVisibility(View.GONE); - return; - } - MediaMetadataCompat metadata = mediaController.getMetadata(); - MediaDescriptionCompat description = metadata == null ? null : metadata.getDescription(); - if (description == null) { - rootView.setVisibility(View.GONE); - return; - } - - PlaybackStateCompat state = mediaController.getPlaybackState(); - MediaRouter.RouteInfo route = MediaRouter.getInstance(getContext()).getSelectedRoute(); - - CharSequence title = description.getTitle(); - boolean hasTitle = !TextUtils.isEmpty(title); - CharSequence subtitle = description.getSubtitle(); - boolean hasSubtitle = !TextUtils.isEmpty(subtitle); - - boolean showTitle = false; - boolean showSubtitle = false; - if (route.getPresentationDisplay() != null && - route.getPresentationDisplay().getDisplayId() != MediaRouter.RouteInfo.PRESENTATION_DISPLAY_ID_NONE) { - // The user is currently casting screen. - titleView.setText(R.string.mr_controller_casting_screen); - showTitle = true; - } else if (state == null || state.getState() == PlaybackStateCompat.STATE_NONE) { - // Show "No media selected" as we don't yet know the playback state. - // (Only exception is bluetooth where we don't show anything.) - if (!route.isBluetooth()) { - titleView.setText(R.string.mr_controller_no_media_selected); - showTitle = true; - } - } else if (!hasTitle && !hasSubtitle) { - titleView.setText(R.string.mr_controller_no_info_available); - showTitle = true; - } else { - if (hasTitle) { - titleView.setText(title); - showTitle = true; - } - if (hasSubtitle) { - subtitleView.setText(subtitle); - showSubtitle = true; - } - } - if (showSubtitle) { - titleView.setSingleLine(); - } else { - titleView.setMaxLines(2); - } - titleView.setVisibility(showTitle ? View.VISIBLE : View.GONE); - subtitleView.setVisibility(showSubtitle ? View.VISIBLE : View.GONE); - - updateState(); - - if(rootView.getVisibility() != View.VISIBLE) { - artView.setVisibility(View.GONE); - rootView.setVisibility(View.VISIBLE); - } - - if (fetchArtSubscription != null) { - fetchArtSubscription.dispose(); - } - - fetchArtSubscription = Observable.fromCallable(() -> fetchArt(description)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - fetchArtSubscription = null; - if (artView == null) { - return; - } - if (result.first != null) { - if (!((Boolean) artView.getTag())) { - artView.setBackgroundColor(result.second); - } - artView.setImageBitmap(result.first); - artView.setVisibility(View.VISIBLE); - } else { - artView.setVisibility(View.GONE); - } - }, error -> Log.e(TAG, Log.getStackTraceString(error))); - - } - - private void updateState() { - PlaybackStateCompat state; - if (!viewsCreated || mediaController == null || - (state = mediaController.getPlaybackState()) == null) { - return; - } - boolean isPlaying = state.getState() == PlaybackStateCompat.STATE_BUFFERING - || state.getState() == PlaybackStateCompat.STATE_PLAYING; - boolean supportsPlay = (state.getActions() & (PlaybackStateCompat.ACTION_PLAY - | PlaybackStateCompat.ACTION_PLAY_PAUSE)) != 0; - boolean supportsPause = (state.getActions() & (PlaybackStateCompat.ACTION_PAUSE - | PlaybackStateCompat.ACTION_PLAY_PAUSE)) != 0; - if (isPlaying && supportsPause) { - playPauseButton.setVisibility(View.VISIBLE); - playPauseButton.setImageResource(getThemeResource(getContext(), R.attr.mediaRoutePauseDrawable)); - playPauseButton.setContentDescription(getContext().getResources().getText(R.string.mr_controller_pause)); - } else if (!isPlaying && supportsPlay) { - playPauseButton.setVisibility(View.VISIBLE); - playPauseButton.setImageResource(getThemeResource(getContext(), R.attr.mediaRoutePlayDrawable)); - playPauseButton.setContentDescription(getContext().getResources().getText(R.string.mr_controller_play)); - } else { - playPauseButton.setVisibility(View.GONE); - } - } - - private static int getThemeResource(Context context, int attr) { - TypedValue value = new TypedValue(); - return context.getTheme().resolveAttribute(attr, value, true) ? value.resourceId : 0; - } - - @NonNull - private Pair<Bitmap, Integer> fetchArt(@NonNull MediaDescriptionCompat description) { - Bitmap iconBitmap = description.getIconBitmap(); - Uri iconUri = description.getIconUri(); - Bitmap art = null; - if (iconBitmap != null) { - art = iconBitmap; - } else if (iconUri != null) { - try { - art = Glide.with(getContext().getApplicationContext()) - .asBitmap() - .load(iconUri.toString()) - .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) - .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) - .get(); - } catch (InterruptedException | ExecutionException e) { - Log.e(TAG, "Image art load failed", e); - } - } - int backgroundColor = 0; - if (art != null && art.getWidth()*9 < art.getHeight()*16) { - // Portrait art requires dominant color as background color. - Palette palette = new Palette.Builder(art).maximumColorCount(1).generate(); - backgroundColor = palette.getSwatches().isEmpty() - ? 0 : palette.getSwatches().get(0).getRgb(); - } - return new Pair<>(art, backgroundColor); - } -} diff --git a/app/src/play/java/de/danoeh/antennapod/fragment/CustomMRControllerDialogFragment.java b/app/src/play/java/de/danoeh/antennapod/fragment/CustomMRControllerDialogFragment.java deleted file mode 100644 index dad7b0bfd..000000000 --- a/app/src/play/java/de/danoeh/antennapod/fragment/CustomMRControllerDialogFragment.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.danoeh.antennapod.fragment; - -import android.content.Context; -import android.os.Bundle; -import androidx.mediarouter.app.MediaRouteControllerDialog; -import androidx.mediarouter.app.MediaRouteControllerDialogFragment; - -import de.danoeh.antennapod.dialog.CustomMRControllerDialog; - -public class CustomMRControllerDialogFragment extends MediaRouteControllerDialogFragment { - @Override - public MediaRouteControllerDialog onCreateControllerDialog(Context context, Bundle savedInstanceState) { - return new CustomMRControllerDialog(context); - } -} diff --git a/app/src/play/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java b/app/src/play/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java deleted file mode 100644 index b51fb40b0..000000000 --- a/app/src/play/java/de/danoeh/antennapod/preferences/PreferenceControllerFlavorHelper.java +++ /dev/null @@ -1,48 +0,0 @@ -package de.danoeh.antennapod.preferences; - -import android.content.Context; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import com.google.android.gms.common.ConnectionResult; -import com.google.android.gms.common.GoogleApiAvailability; - -import de.danoeh.antennapod.PodcastApp; -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.fragment.preferences.PlaybackPreferencesFragment; - -/** - * Implements functions from PreferenceController that are flavor dependent. - */ -public class PreferenceControllerFlavorHelper { - - public static void setupFlavoredUI(PlaybackPreferencesFragment ui) { - //checks whether Google Play Services is installed on the device (condition necessary for Cast support) - ui.findPreference(UserPreferences.PREF_CAST_ENABLED).setOnPreferenceChangeListener((preference, o) -> { - if (o instanceof Boolean && ((Boolean) o)) { - final int googlePlayServicesCheck = GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(ui.getActivity()); - if (googlePlayServicesCheck == ConnectionResult.SUCCESS) { - displayRestartRequiredDialog(ui.requireContext()); - return true; - } else { - GoogleApiAvailability.getInstance() - .getErrorDialog(ui.getActivity(), googlePlayServicesCheck, 0) - .show(); - return false; - } - } - return true; - }); - } - - private static void displayRestartRequiredDialog(@NonNull Context context) { - AlertDialog.Builder dialog = new AlertDialog.Builder(context); - dialog.setTitle(android.R.string.dialog_alert_title); - dialog.setMessage(R.string.pref_restart_required); - dialog.setPositiveButton(android.R.string.ok, (dialog1, which) -> PodcastApp.forceRestart()); - dialog.setCancelable(false); - dialog.show(); - } -} diff --git a/app/src/play/res/layout/media_router_controller.xml b/app/src/play/res/layout/media_router_controller.xml deleted file mode 100644 index bdb1b1cc2..000000000 --- a/app/src/play/res/layout/media_router_controller.xml +++ /dev/null @@ -1,41 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/mrc_playback_control" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="@dimen/media_router_controller_playback_control_vertical_padding" - android:paddingBottom="@dimen/media_router_controller_playback_control_vertical_padding" - android:paddingLeft="@dimen/media_router_controller_playback_control_start_padding" - android:paddingStart="@dimen/media_router_controller_playback_control_start_padding" - android:paddingRight="@dimen/media_router_controller_playback_control_horizontal_spacing" - android:paddingEnd="@dimen/media_router_controller_playback_control_horizontal_spacing"> - <ImageButton android:id="@+id/mrc_control_play_pause" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginLeft="@dimen/media_router_controller_playback_control_horizontal_spacing" - android:layout_marginStart="@dimen/media_router_controller_playback_control_horizontal_spacing" - android:layout_alignParentRight="true" - android:layout_alignParentEnd="true" - android:contentDescription="@string/mr_controller_play" - android:background="?android:attr/selectableItemBackground"/> - - <LinearLayout android:id="@+id/mrc_control_title_container" - android:orientation="vertical" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_alignParentLeft="true" - android:layout_alignParentStart="true" - android:layout_toLeftOf="@id/mrc_control_play_pause" - android:layout_toStartOf="@id/mrc_control_play_pause" - android:layout_centerVertical="true"> - <TextView android:id="@+id/mrc_control_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textAppearance="@style/TextAppearance.MediaRouter.PrimaryText"/> - <TextView android:id="@+id/mrc_control_subtitle" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textAppearance="@style/TextAppearance.MediaRouter.SecondaryText" - android:singleLine="true" /> - </LinearLayout> -</RelativeLayout> diff --git a/build.gradle b/build.gradle index f6992882c..219510eeb 100644 --- a/build.gradle +++ b/build.gradle @@ -3,12 +3,11 @@ buildscript { google() mavenCentral() gradlePluginPortal() - jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.3' - classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:1.0.4' - classpath 'de.timfreiheit.resourceplaceholders:placeholders:0.3' + classpath 'com.android.tools.build:gradle:4.2.2' + classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:3.0.0' + classpath 'de.timfreiheit.resourceplaceholders:placeholders:0.4' classpath "gradle.plugin.com.github.spotbugs.snom:spotbugs-gradle-plugin:4.7.0" } } @@ -18,7 +17,6 @@ allprojects { google() mavenCentral() maven { url "https://jitpack.io" } - jcenter() } } @@ -42,8 +40,12 @@ project.ext { annotationVersion = "1.2.0" appcompatVersion = "1.3.1" coreVersion = "1.5.0" - mediaVersion = "1.1.0" + fragmentVersion = "1.3.6" + mediaVersion = "1.4.3" + paletteVersion = "1.0.0" preferenceVersion = "1.1.1" + recyclerViewVersion = "1.2.1" + viewPager2Version = "1.1.0-beta01" workManagerVersion = "2.3.4" googleMaterialVersion = "1.1.0" @@ -58,11 +60,11 @@ project.ext { rxAndroidVersion = "2.1.1" rxJavaVersion = "2.2.2" iconifyVersion = "2.2.2" - exoPlayerVersion = "2.11.8" + exoPlayerVersion = "2.14.2" audioPlayerVersion = "v2.0.0" // Only used for free builds. This version should be updated regularly. - conscryptVersion = "2.4.0" + conscryptVersion = "2.5.2" // Google Play build wearableSupportVersion = "2.6.0" @@ -70,6 +72,8 @@ project.ext { //Tests awaitilityVersion = "3.1.6" + junitVersion = "4.13" + robolectricVersion = "4.5-alpha-1" robotiumSoloVersion = "5.6.3" espressoVersion = "3.2.0" runnerVersion = "1.2.0" diff --git a/common.gradle b/common.gradle index 0300ed534..fb3045f81 100644 --- a/common.gradle +++ b/common.gradle @@ -1,8 +1,8 @@ android { - compileSdkVersion 30 + compileSdkVersion 31 defaultConfig { - minSdkVersion 16 + minSdkVersion 19 targetSdkVersion 30 multiDexEnabled false diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml index 1f06877a6..97a7481f5 100644 --- a/config/spotbugs/exclude.xml +++ b/config/spotbugs/exclude.xml @@ -62,7 +62,7 @@ </Match> <Match> <Bug pattern="UUF_UNUSED_PUBLIC_OR_PROTECTED_FIELD"/> - <Class name="de.danoeh.antennapod.core.storage.NavDrawerData$FolderDrawerItem"/> + <Class name="de.danoeh.antennapod.core.storage.NavDrawerData$TagDrawerItem"/> </Match> <Match> <Bug pattern="UWF_UNWRITTEN_PUBLIC_OR_PROTECTED_FIELD"/> diff --git a/core/build.gradle b/core/build.gradle index 953a85a97..700487701 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,7 +37,9 @@ 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.palette:palette:$paletteVersion" implementation "androidx.preference:preference:$preferenceVersion" implementation "androidx.work:work-runtime:$workManagerVersion" implementation "com.google.android.material:material:$googleMaterialVersion" @@ -55,20 +60,18 @@ 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" testImplementation 'androidx.test:core:1.2.0' testImplementation "org.awaitility:awaitility:$awaitilityVersion" - testImplementation 'junit:junit:4.13' + testImplementation "junit:junit:$junitVersion" testImplementation 'org.mockito:mockito-inline:3.5.13' - testImplementation 'org.robolectric:robolectric:4.5-alpha-1' + testImplementation "org.robolectric:robolectric:$robolectricVersion" testImplementation 'javax.inject:javax.inject:1' androidTestImplementation "com.jayway.android.robotium:robotium-solo:$robotiumSoloVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" 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/export/favorites/FavoritesWriter.java b/core/src/main/java/de/danoeh/antennapod/core/export/favorites/FavoritesWriter.java index 9bc273c9e..29de6ca80 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/export/favorites/FavoritesWriter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/export/favorites/FavoritesWriter.java @@ -117,10 +117,17 @@ public class FavoritesWriter implements ExportWriter { } private void writeFavoriteItem(Writer writer, FeedItem item, String favoriteTemplate) throws IOException { - String favItem = favoriteTemplate - .replace("{FAV_TITLE}", item.getTitle().trim()) - .replace("{FAV_WEBSITE}", item.getLink()) - .replace("{FAV_MEDIA}", item.getMedia().getDownload_url()); + String favItem = favoriteTemplate.replace("{FAV_TITLE}", item.getTitle().trim()); + if (item.getLink() != null) { + favItem = favItem.replace("{FAV_WEBSITE}", item.getLink()); + } else { + favItem = favItem.replace("{FAV_WEBSITE}", ""); + } + if (item.getMedia() != null && item.getMedia().getDownload_url() != null) { + favItem = favItem.replace("{FAV_MEDIA}", item.getMedia().getDownload_url()); + } else { + favItem = favItem.replace("{FAV_MEDIA}", ""); + } writer.append(favItem); } 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..797addcc1 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 @@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.glide; import android.annotation.SuppressLint; import android.content.Context; +import android.graphics.Bitmap; import android.util.Log; import androidx.annotation.NonNull; @@ -11,7 +12,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,8 +43,9 @@ 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()); + registry.register(Bitmap.class, PaletteBitmap.class, new PaletteBitmapTranscoder()); } } 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/glide/PaletteBitmap.java b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmap.java new file mode 100644 index 000000000..59ecd3d0d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmap.java @@ -0,0 +1,20 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.core.glide; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.palette.graphics.Palette; + +public class PaletteBitmap { + public final Palette palette; + public final Bitmap bitmap; + + public PaletteBitmap(@NonNull Bitmap bitmap, Palette palette) { + this.bitmap = bitmap; + this.palette = palette; + } +}
\ No newline at end of file diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapResource.java b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapResource.java new file mode 100644 index 000000000..fef0bccd3 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapResource.java @@ -0,0 +1,40 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.core.glide; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.util.Util; + +public class PaletteBitmapResource implements Resource<PaletteBitmap> { + private final PaletteBitmap paletteBitmap; + + public PaletteBitmapResource(@NonNull PaletteBitmap paletteBitmap) { + this.paletteBitmap = paletteBitmap; + } + + @NonNull + @Override + public Class<PaletteBitmap> getResourceClass() { + return PaletteBitmap.class; + } + + @NonNull + @Override + public PaletteBitmap get() { + return paletteBitmap; + } + + @Override + public int getSize() { + return Util.getBitmapByteSize(paletteBitmap.bitmap); + } + + @Override + public void recycle() { + paletteBitmap.bitmap.recycle(); + } +}
\ No newline at end of file diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapTranscoder.java b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapTranscoder.java new file mode 100644 index 000000000..a6a606cb8 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/PaletteBitmapTranscoder.java @@ -0,0 +1,32 @@ +/* + * Source: https://github.com/bumptech/glide/wiki/Custom-targets#palette-example + */ + +package de.danoeh.antennapod.core.glide; + +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.palette.graphics.Palette; + +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.engine.Resource; +import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; + +import de.danoeh.antennapod.core.preferences.UserPreferences; + +public class PaletteBitmapTranscoder implements ResourceTranscoder<Bitmap, PaletteBitmap> { + + @Nullable + @Override + public Resource<PaletteBitmap> transcode(@NonNull Resource<Bitmap> toTranscode, @NonNull Options options) { + Bitmap bitmap = toTranscode.get(); + Palette palette = null; + if (UserPreferences.shouldShowSubscriptionTitle()) { + palette = new Palette.Builder(bitmap).generate(); + } + PaletteBitmap result = new PaletteBitmap(bitmap, palette); + return new PaletteBitmapResource(result); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ResizingOkHttpStreamFetcher.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ResizingOkHttpStreamFetcher.java index 11e2f944e..1871723bb 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/glide/ResizingOkHttpStreamFetcher.java +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ResizingOkHttpStreamFetcher.java @@ -3,12 +3,13 @@ package de.danoeh.antennapod.core.glide; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Build; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.bumptech.glide.Priority; import com.bumptech.glide.integration.okhttp3.OkHttpStreamFetcher; import com.bumptech.glide.load.model.GlideUrl; -import com.google.android.exoplayer2.util.Log; import okhttp3.Call; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; @@ -22,7 +23,7 @@ import java.io.InputStream; import java.io.OutputStream; public class ResizingOkHttpStreamFetcher extends OkHttpStreamFetcher { - private static final String TAG = "ResizingOkHttpStreamFetcher"; + private static final String TAG = "ResizingOkHttpStreamFet"; private static final int MAX_DIMENSIONS = 1500; private static final int MAX_FILE_SIZE = 1024 * 1024; // 1 MB 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..08daf01e2 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 @@ -69,6 +69,7 @@ public class UserPreferences { public static final String PREF_BACK_BUTTON_BEHAVIOR = "prefBackButtonBehavior"; private static final String PREF_BACK_BUTTON_GO_TO_PAGE = "prefBackButtonGoToPage"; public static final String PREF_FILTER_FEED = "prefSubscriptionsFilter"; + public static final String PREF_SUBSCRIPTION_TITLE = "prefSubscriptionTitle"; public static final String PREF_QUEUE_KEEP_SORTED = "prefQueueKeepSorted"; public static final String PREF_QUEUE_KEEP_SORTED_ORDER = "prefQueueKeepSortedOrder"; @@ -85,7 +86,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 +208,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 +468,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 +531,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 +611,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 { @@ -1084,4 +1091,13 @@ public class UserPreferences { public static void unsetUsageCountingDate() { setUsageCountingDateMillis(-1); } + + public static boolean shouldShowSubscriptionTitle() { + return prefs.getBoolean(PREF_SUBSCRIPTION_TITLE, false); + } + + public static void setSubscriptionTitleSetting(boolean showTitle) { + prefs.edit().putBoolean(PREF_SUBSCRIPTION_TITLE, showTitle).apply(); + } + } 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..6cf6ce107 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 @@ -8,6 +8,7 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Binder; +import android.os.Build; import android.os.Handler; import android.os.IBinder; import android.os.Looper; @@ -20,7 +21,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 +40,8 @@ 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.core.util.download.ConnectionStateMonitor; +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; @@ -121,7 +122,7 @@ public class DownloadService extends Service { private static final int SCHED_EX_POOL_SIZE = 1; private final ScheduledThreadPoolExecutor schedExecutor; private static DownloaderFactory downloaderFactory = new DefaultDownloaderFactory(); - + private ConnectionStateMonitor connectionMonitor; private final IBinder mBinder = new LocalBinder(); private class LocalBinder extends Binder { @@ -192,6 +193,11 @@ public class DownloadService extends Service { cancelDownloadReceiverFilter.addAction(ACTION_CANCEL_DOWNLOAD); registerReceiver(cancelDownloadReceiver, cancelDownloadReceiverFilter); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + connectionMonitor = new ConnectionStateMonitor(); + connectionMonitor.enable(getApplicationContext()); + } + downloadCompletionThread.start(); } @@ -226,10 +232,9 @@ public class DownloadService extends Service { downloadPostFuture.cancel(true); } 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); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + connectionMonitor.disable(getApplicationContext()); + } // start auto download in case anything new has shown up DBTasks.autodownloadUndownloadedItems(getApplicationContext()); @@ -326,18 +331,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..d4008b3f2 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,36 +5,43 @@ 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; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; 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.OkHttpDataSource; import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; 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; @@ -42,6 +49,7 @@ import org.antennapod.audio.MediaPlayer; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.concurrent.TimeUnit; @@ -54,7 +62,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; @@ -82,12 +90,12 @@ public class ExoPlayerWrapper implements IPlayer { trackSelector = new DefaultTrackSelector(context); exoPlayer = new SimpleExoPlayer.Builder(context, new DefaultRenderersFactory(context)) .setTrackSelector(trackSelector) - .setLoadControl(loadControl.createDefaultLoadControl()) + .setLoadControl(loadControl.build()) .build(); exoPlayer.setSeekParameters(SeekParameters.EXACT); - exoPlayer.addListener(new Player.EventListener() { + exoPlayer.addListener(new Player.Listener() { @Override - public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + public void onPlaybackStateChanged(@Player.State int playbackState) { if (audioCompletionListener != null && playbackState == Player.STATE_ENDED) { audioCompletionListener.onCompletion(null); } else if (infoListener != null && playbackState == Player.STATE_BUFFERING) { @@ -98,15 +106,25 @@ 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()); + } } } @Override - public void onSeekProcessed() { - if (audioSeekCompleteListener != null) { + public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, + @NonNull Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + if (audioSeekCompleteListener != null && reason == Player.DISCONTINUITY_REASON_SEEK) { audioSeekCompleteListener.onSeekComplete(null); } } @@ -143,12 +161,13 @@ public class ExoPlayerWrapper implements IPlayer { @Override public void pause() { - exoPlayer.setPlayWhenReady(false); + exoPlayer.pause(); } @Override public void prepare() throws IllegalStateException { - exoPlayer.prepare(mediaSource, false, true); + exoPlayer.setMediaSource(mediaSource, false); + exoPlayer.prepare(); } @Override @@ -184,31 +203,31 @@ public class ExoPlayerWrapper implements IPlayer { b.setContentType(i); b.setFlags(a.flags); b.setUsage(a.usage); - exoPlayer.setAudioAttributes(b.build()); + exoPlayer.setAudioAttributes(b.build(), false); } 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); + final OkHttpDataSource.Factory httpDataSourceFactory = + new OkHttpDataSource.Factory(AntennapodHttpClient.getHttpClient()) + .setUserAgent(ClientConfig.USER_AGENT); if (!TextUtils.isEmpty(user) && !TextUtils.isEmpty(password)) { - httpDataSourceFactory.getDefaultRequestProperties().set("Authorization", - HttpDownloader.encodeCredentials( - user, - password, - "ISO-8859-1")); + final HashMap<String, String> requestProperties = new HashMap<>(); + requestProperties.put( + "Authorization", + HttpDownloader.encodeCredentials(user, password, "ISO-8859-1") + ); + httpDataSourceFactory.setDefaultRequestProperties(requestProperties); } DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, null, httpDataSourceFactory); DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); extractorsFactory.setConstantBitrateSeekingEnabled(true); extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_DISABLE_ID3_METADATA); ProgressiveMediaSource.Factory f = new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory); - mediaSource = f.createMediaSource(Uri.parse(s)); + final MediaItem mediaItem = MediaItem.fromUri(Uri.parse(s)); + mediaSource = f.createMediaSource(mediaItem); } @Override @@ -223,7 +242,8 @@ public class ExoPlayerWrapper implements IPlayer { @Override public void setPlaybackParams(float speed, boolean skipSilence) { - playbackParameters = new PlaybackParameters(speed, playbackParameters.pitch, skipSilence); + playbackParameters = new PlaybackParameters(speed, playbackParameters.pitch); + exoPlayer.setSkipSilenceEnabled(skipSilence); exoPlayer.setPlaybackParameters(playbackParameters); } @@ -244,7 +264,7 @@ public class ExoPlayerWrapper implements IPlayer { @Override public void start() { - exoPlayer.setPlayWhenReady(true); + exoPlayer.play(); // Can't set params when paused - so always set it on start in case they changed exoPlayer.setPlaybackParameters(playbackParameters); } @@ -304,7 +324,7 @@ public class ExoPlayerWrapper implements IPlayer { TrackSelectionArray trackSelections = exoPlayer.getCurrentTrackSelections(); List<Format> availableFormats = getFormats(); for (int i = 0; i < trackSelections.length; i++) { - TrackSelection track = trackSelections.get(i); + ExoTrackSelection track = (ExoTrackSelection) trackSelections.get(i); if (track == null) { continue; } @@ -323,7 +343,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 8ba5215df..805956094 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) { @@ -1235,7 +1231,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { | PlaybackStateCompat.ACTION_PAUSE | PlaybackStateCompat.ACTION_FAST_FORWARD | PlaybackStateCompat.ACTION_SKIP_TO_NEXT - | PlaybackStateCompat.ACTION_SEEK_TO; + | PlaybackStateCompat.ACTION_SEEK_TO + | PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED; if (useSkipToPreviousForRewindInLockscreen()) { // Workaround to fool Android so that Lockscreen will expose a skip-to-previous button, @@ -1268,15 +1265,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 +1317,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 +1355,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 +1563,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(); } } @@ -1892,6 +1889,12 @@ public class PlaybackService extends MediaBrowserServiceCompat { } @Override + public void onSetPlaybackSpeed(float speed) { + Log.d(TAG, "onSetPlaybackSpeed()"); + setSpeed(speed); + } + + @Override public boolean onMediaButtonEvent(final Intent mediaButton) { Log.d(TAG, "onMediaButtonEvent(" + mediaButton + ")"); if (mediaButton != null) { @@ -1920,96 +1923,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/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 a0c1e54ad..412914329 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. @@ -462,7 +464,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..280a2f118 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 @@ -6,9 +6,8 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.app.NotificationManagerCompat; -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 +24,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. @@ -129,16 +129,17 @@ public class DBWriter { if (media.getId() == PlaybackPreferences.getCurrentlyPlayingFeedMediaId()) { PlaybackPreferences.writeNoMediaPlaying(); IntentUtils.sendLocalBroadcast(context, PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE); + + NotificationManagerCompat nm = NotificationManagerCompat.from(context); + nm.cancel(R.id.notification_playing); } // 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; @@ -152,7 +153,6 @@ public class DBWriter { */ public static Future<?> deleteFeed(final Context context, final long feedId) { return dbExec.submit(() -> { - DownloadRequester requester = DownloadRequester.getInstance(); final Feed feed = DBReader.getFeed(feedId); if (feed == null) { return; @@ -170,7 +170,9 @@ public class DBWriter { adapter.removeFeed(feed); adapter.close(); - SyncService.enqueueFeedRemoved(context, feed.getDownload_url()); + if (!feed.isLocalFeed()) { + SynchronizationQueueSink.enqueueFeedRemovedIfSynchronizationIsActive(context, feed.getDownload_url()); + } EventBus.getDefault().post(new FeedListUpdateEvent(feed)); }); } @@ -782,7 +784,9 @@ public class DBWriter { adapter.close(); for (Feed feed : feeds) { - SyncService.enqueueFeedAdded(context, feed.getDownload_url()); + if (!feed.isLocalFeed()) { + SynchronizationQueueSink.enqueueFeedAddedIfSynchronizationIsActive(context, feed.getDownload_url()); + } } BackupManager backupManager = new BackupManager(context); @@ -953,25 +957,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..b7e221a33 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 @@ -44,8 +44,6 @@ import de.danoeh.antennapod.model.feed.SortOrder; import static de.danoeh.antennapod.model.feed.FeedPreferences.SPEED_USE_GLOBAL; import static de.danoeh.antennapod.model.feed.SortOrder.toCodeString; -// TODO Remove media column from feeditem table - /** * Implements methods for accessing the database */ @@ -53,7 +51,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 +95,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 +112,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 +140,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 +168,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 +245,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 +258,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 +294,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 +444,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 +452,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 +648,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 +764,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 { @@ -844,43 +840,32 @@ public class PodDBAdapter { db.delete(TABLE_NAME_QUEUE, null, null); } - private void removeFeedMedia(FeedMedia media) { - // delete download log entries for feed media - db.delete(TABLE_NAME_DOWNLOAD_LOG, KEY_FEEDFILE + "=? AND " + KEY_FEEDFILETYPE + "=?", - new String[]{String.valueOf(media.getId()), String.valueOf(FeedMedia.FEEDFILETYPE_FEEDMEDIA)}); - - db.delete(TABLE_NAME_FEED_MEDIA, KEY_ID + "=?", - new String[]{String.valueOf(media.getId())}); - } - - private void removeChaptersOfItem(FeedItem item) { - db.delete(TABLE_NAME_SIMPLECHAPTERS, KEY_FEEDITEM + "=?", - new String[]{String.valueOf(item.getId())}); - } - - /** - * Remove a FeedItem and its FeedMedia entry. - */ - private void removeFeedItem(FeedItem item) { - if (item.getMedia() != null) { - removeFeedMedia(item.getMedia()); - } - if (item.hasChapters() || item.getChapters() != null) { - removeChaptersOfItem(item); - } - db.delete(TABLE_NAME_FEED_ITEMS, KEY_ID + "=?", - new String[]{String.valueOf(item.getId())}); - } - /** * Remove the listed items and their FeedMedia entries. */ public void removeFeedItems(@NonNull List<FeedItem> items) { try { - db.beginTransactionNonExclusive(); + StringBuilder mediaIds = new StringBuilder(); + StringBuilder itemIds = new StringBuilder(); for (FeedItem item : items) { - removeFeedItem(item); + if (item.getMedia() != null) { + if (mediaIds.length() != 0) { + mediaIds.append(","); + } + mediaIds.append(item.getMedia().getId()); + } + if (itemIds.length() != 0) { + itemIds.append(","); + } + itemIds.append(item.getId()); } + + db.beginTransactionNonExclusive(); + db.delete(TABLE_NAME_SIMPLECHAPTERS, KEY_FEEDITEM + " IN (" + itemIds + ")", null); + db.delete(TABLE_NAME_DOWNLOAD_LOG, KEY_FEEDFILETYPE + "=" + FeedMedia.FEEDFILETYPE_FEEDMEDIA + + " AND " + KEY_FEEDFILE + " IN (" + mediaIds + ")", null); + db.delete(TABLE_NAME_FEED_MEDIA, KEY_ID + " IN (" + mediaIds + ")", null); + db.delete(TABLE_NAME_FEED_ITEMS, KEY_ID + " IN (" + itemIds + ")", null); db.setTransactionSuccessful(); } catch (SQLException e) { Log.e(TAG, Log.getStackTraceString(e)); @@ -896,9 +881,7 @@ public class PodDBAdapter { try { db.beginTransactionNonExclusive(); if (feed.getItems() != null) { - for (FeedItem item : feed.getItems()) { - removeFeedItem(item); - } + removeFeedItems(feed.getItems()); } // delete download log entries for feed db.delete(TABLE_NAME_DOWNLOAD_LOG, KEY_FEEDFILE + "=? AND " + KEY_FEEDFILETYPE + "=?", @@ -1123,7 +1106,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); } @@ -1362,25 +1344,7 @@ public class PodDBAdapter { } /** - * Select number of items, new items, the date of the latest episode and the number of episodes in progress. The result - * is sorted by the title of the feed. - */ - private static final String FEED_STATISTICS_QUERY = "SELECT Feeds.id, num_items, new_items, latest_episode, in_progress FROM " + - " Feeds LEFT JOIN " + - "(SELECT feed,count(*) AS num_items," + - " COUNT(CASE WHEN read=0 THEN 1 END) AS new_items," + - " MAX(pubDate) AS latest_episode," + - " COUNT(CASE WHEN position>0 THEN 1 END) AS in_progress," + - " COUNT(CASE WHEN downloaded=1 THEN 1 END) AS episodes_downloaded " + - " FROM FeedItems LEFT JOIN FeedMedia ON FeedItems.id=FeedMedia.feeditem GROUP BY FeedItems.feed)" + - " ON Feeds.id = feed ORDER BY Feeds.title COLLATE NOCASE ASC;"; - - public Cursor getFeedStatisticsCursor() { - return db.rawQuery(FEED_STATISTICS_QUERY, null); - } - - /** - * Insert raw data to the database. * + * Insert raw data to the database. * Call method only for unit tests. */ @VisibleForTesting(otherwise = VisibleForTesting.NONE) 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..efc7845a4 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 @@ -8,7 +8,6 @@ import android.net.NetworkInfo; import android.net.wifi.WifiInfo; import android.net.wifi.WifiManager; import android.os.Build; -import androidx.core.net.ConnectivityManagerCompat; import android.text.TextUtils; import android.util.Log; @@ -16,7 +15,11 @@ 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.core.storage.DBTasks; +import de.danoeh.antennapod.core.storage.DownloadRequester; import de.danoeh.antennapod.model.feed.FeedMedia; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; @@ -30,6 +33,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 +45,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() { @@ -126,7 +98,18 @@ public class NetworkUtils { private static boolean isNetworkMetered() { ConnectivityManager connManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - return ConnectivityManagerCompat.isActiveNetworkMetered(connManager); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + NetworkCapabilities capabilities = connManager.getNetworkCapabilities( + connManager.getActiveNetwork()); + + if (capabilities != null + && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + return false; + } + } + return connManager.isActiveNetworkMetered(); } private static boolean isNetworkCellular() { @@ -157,6 +140,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 +158,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()) { @@ -225,4 +230,16 @@ public class NetworkUtils { .observeOn(AndroidSchedulers.mainThread()); } + public static void networkChangedDetected() { + if (NetworkUtils.isAutoDownloadAllowed()) { + Log.d(TAG, "auto-dl network available, starting auto-download"); + DBTasks.autodownloadUndownloadedItems(context); + } else { // if new network is Wi-Fi, finish ongoing downloads, + // otherwise cancel all downloads + if (NetworkUtils.isNetworkRestricted()) { + Log.i(TAG, "Device is no longer connected to Wi-Fi. Cancelling ongoing downloads"); + DownloadRequester.getInstance().cancelAllDownloads(context); + } + } + } } 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/comparator/CompareCompat.java b/core/src/main/java/de/danoeh/antennapod/core/util/comparator/CompareCompat.java deleted file mode 100644 index c189f2389..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/comparator/CompareCompat.java +++ /dev/null @@ -1,29 +0,0 @@ -package de.danoeh.antennapod.core.util.comparator; - -/** - * Some compare() methods are not available before API 19. - * This class provides fallbacks - */ -public class CompareCompat { - - private CompareCompat() { - // Must not be instantiated - } - - /** - * Compares two {@code long} values. Long.compare() is not available before API 19 - * - * @return 0 if long1 = long2, less than 0 if long1 < long2, - * and greater than 0 if long1 > long2. - */ - public static int compareLong(long long1, long long2) { - //noinspection UseCompareMethod - if (long1 > long2) { - return -1; - } else if (long1 < long2) { - return 1; - } else { - return 0; - } - } -} 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/download/ConnectionStateMonitor.java b/core/src/main/java/de/danoeh/antennapod/core/util/download/ConnectionStateMonitor.java new file mode 100644 index 000000000..5c543bf5a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/download/ConnectionStateMonitor.java @@ -0,0 +1,46 @@ +package de.danoeh.antennapod.core.util.download; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.RequiresApi; +import de.danoeh.antennapod.core.util.NetworkUtils; + +@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) +public class ConnectionStateMonitor + extends ConnectivityManager.NetworkCallback + implements ConnectivityManager.OnNetworkActiveListener { + private static final String TAG = "ConnectionStateMonitor"; + final NetworkRequest networkRequest; + + public ConnectionStateMonitor() { + networkRequest = new NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build(); + } + + @Override + public void onNetworkActive() { + Log.d(TAG, "ConnectionStateMonitor::onNetworkActive network connection changed"); + NetworkUtils.networkChangedDetected(); + } + + public void enable(Context context) { + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + connectivityManager.registerNetworkCallback(networkRequest, this); + connectivityManager.addDefaultNetworkActiveListener(this); + } + + public void disable(Context context) { + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + connectivityManager.unregisterNetworkCallback(this); + connectivityManager.removeDefaultNetworkActiveListener(this); + } +}
\ No newline at end of file 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..d6915b76b 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> @@ -187,7 +186,9 @@ <item quantity="one">%d subscription updated.</item> <item quantity="other">%d subscriptions updated.</item> </plurals> - <string name="add_to_folder">Add to folder</string> + <string name="edit_tags">Edit tags</string> + <string name="rename_tag_label">Rename tag</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> @@ -202,8 +203,8 @@ <string name="delete_failed">Unable to delete file. Rebooting the device could help.</string> <string name="delete_episode_label">Delete Episode</string> <plurals name="deleted_multi_episode_batch_label"> - <item quantity="one">%d episode selected, %d download deleted.</item> - <item quantity="other">%d episodes selected, %d download(s) deleted.</item> + <item quantity="one">1 downloaded episode deleted.</item> + <item quantity="other">%d downloaded episodes deleted.</item> </plurals> <string name="remove_new_flag_label">Remove \"new\" flag</string> <string name="removed_new_flag_label">Removed \"new\" flag</string> @@ -263,8 +264,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 +295,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 +356,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 +447,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 +503,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> @@ -540,6 +540,8 @@ <string name="new_episode_notification_disabled">Notification disabled</string> <string name="pref_feed_settings_dialog_msg">This setting is unique to each podcast. You can change it by opening the podcast page.</string> <string name="pref_contribute">Contribute</string> + <string name="pref_show_subscription_title">Show Subscription Title</string> + <string name="pref_show_subscription_title_summary">Display the subscription title below the cover image.</string> <!-- About screen --> <string name="about_pref">About</string> @@ -565,6 +567,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 +597,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 +664,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 +672,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 +691,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 +807,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 +834,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/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/storage/DbWriterTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java index f70ed6e29..5e73773db 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java @@ -188,14 +188,13 @@ public class DbWriterTest { assertTrue(queue.size() != 0); DBWriter.deleteFeedMediaOfItem(context, media.getId()); - Awaitility.await().until(() -> !dest.exists()); + Awaitility.await().timeout(2, TimeUnit.SECONDS).until(() -> !dest.exists()); media = DBReader.getFeedMedia(media.getId()); assertNotNull(media); assertFalse(dest.exists()); assertFalse(media.isDownloaded()); assertNull(media.getFile_url()); - queue = DBReader.getQueue(); - assertEquals(0, queue.size()); + Awaitility.await().timeout(2, TimeUnit.SECONDS).until(() -> DBReader.getQueue().isEmpty()); } @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/event/build.gradle b/event/build.gradle new file mode 100644 index 000000000..c852c0351 --- /dev/null +++ b/event/build.gradle @@ -0,0 +1,8 @@ +apply plugin: "com.android.library" +apply from: "../common.gradle" + +dependencies { + implementation project(':model') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" +} diff --git a/event/src/main/AndroidManifest.xml b/event/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7561cf555 --- /dev/null +++ b/event/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="de.danoeh.antennapod.event" /> diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/DiscoveryDefaultUpdateEvent.java b/event/src/main/java/de/danoeh/antennapod/event/DiscoveryDefaultUpdateEvent.java index f7757935a..944c7759a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/DiscoveryDefaultUpdateEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/DiscoveryDefaultUpdateEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; public class DiscoveryDefaultUpdateEvent { public DiscoveryDefaultUpdateEvent() { diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/FavoritesEvent.java b/event/src/main/java/de/danoeh/antennapod/event/FavoritesEvent.java index cbfcc37e6..8b27f74ab 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FavoritesEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/FavoritesEvent.java @@ -1,9 +1,4 @@ -package de.danoeh.antennapod.core.event; - -import androidx.annotation.NonNull; - -import org.apache.commons.lang3.builder.ToStringBuilder; -import org.apache.commons.lang3.builder.ToStringStyle; +package de.danoeh.antennapod.event; import de.danoeh.antennapod.model.feed.FeedItem; @@ -28,14 +23,4 @@ public class FavoritesEvent { 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/event/src/main/java/de/danoeh/antennapod/event/FeedItemEvent.java index 99cb01714..6c7adc2d7 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FeedItemEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/FeedItemEvent.java @@ -1,11 +1,8 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.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; @@ -41,14 +38,4 @@ public class FeedItemEvent { 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/event/src/main/java/de/danoeh/antennapod/event/FeedListUpdateEvent.java index 4ed8e33ec..99a83abec 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/FeedListUpdateEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/FeedListUpdateEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; import de.danoeh.antennapod.model.feed.Feed; diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/MessageEvent.java b/event/src/main/java/de/danoeh/antennapod/event/MessageEvent.java index 9fb22b8ea..3f6b2db32 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/MessageEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/MessageEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; import androidx.annotation.Nullable; diff --git a/event/src/main/java/de/danoeh/antennapod/event/PlayerErrorEvent.java b/event/src/main/java/de/danoeh/antennapod/event/PlayerErrorEvent.java new file mode 100644 index 000000000..662a16f81 --- /dev/null +++ b/event/src/main/java/de/danoeh/antennapod/event/PlayerErrorEvent.java @@ -0,0 +1,13 @@ +package de.danoeh.antennapod.event; + +public class PlayerErrorEvent { + private final String message; + + public PlayerErrorEvent(String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlayerStatusEvent.java b/event/src/main/java/de/danoeh/antennapod/event/PlayerStatusEvent.java index fe7f17968..4074bd98f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlayerStatusEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/PlayerStatusEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; public class PlayerStatusEvent { public PlayerStatusEvent() { diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/QueueEvent.java b/event/src/main/java/de/danoeh/antennapod/event/QueueEvent.java index c866939bd..578398865 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/QueueEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/QueueEvent.java @@ -1,10 +1,7 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.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; @@ -58,14 +55,4 @@ public class QueueEvent { 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/SyncServiceEvent.java b/event/src/main/java/de/danoeh/antennapod/event/SyncServiceEvent.java index 7aa5f6bf1..2ebac8c0a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/SyncServiceEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/SyncServiceEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; public class SyncServiceEvent { private final int messageResId; diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/UnreadItemsUpdateEvent.java b/event/src/main/java/de/danoeh/antennapod/event/UnreadItemsUpdateEvent.java index c3efbfe8b..fb1bbc739 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/UnreadItemsUpdateEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/UnreadItemsUpdateEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event; public class UnreadItemsUpdateEvent { public UnreadItemsUpdateEvent() { diff --git a/event/src/main/java/de/danoeh/antennapod/event/playback/BufferUpdateEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/BufferUpdateEvent.java new file mode 100644 index 000000000..57d41ad13 --- /dev/null +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/BufferUpdateEvent.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.event.playback; + +public class BufferUpdateEvent { + private static final float PROGRESS_STARTED = -1; + private static final float PROGRESS_ENDED = -2; + final float progress; + + private BufferUpdateEvent(float progress) { + this.progress = progress; + } + + public static BufferUpdateEvent started() { + return new BufferUpdateEvent(PROGRESS_STARTED); + } + + public static BufferUpdateEvent ended() { + return new BufferUpdateEvent(PROGRESS_ENDED); + } + + public static BufferUpdateEvent progressUpdate(float progress) { + return new BufferUpdateEvent(progress); + } + + public float getProgress() { + return progress; + } + + public boolean hasStarted() { + return progress == PROGRESS_STARTED; + } + + public boolean hasEnded() { + return progress == PROGRESS_ENDED; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackHistoryEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackHistoryEvent.java index cd3f27bf5..b51377a3d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackHistoryEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackHistoryEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event.playback; public class PlaybackHistoryEvent { diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackPositionEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackPositionEvent.java index 3327d8a02..3746680eb 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/PlaybackPositionEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackPositionEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event.playback; public class PlaybackPositionEvent { private final int position; diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackServiceEvent.java index 2230ee84f..8115730dd 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/PlaybackServiceEvent.java @@ -1,6 +1,6 @@ -package de.danoeh.antennapod.core.event; +package de.danoeh.antennapod.event.playback; -public class ServiceEvent { +public class PlaybackServiceEvent { public enum Action { SERVICE_STARTED, SERVICE_SHUT_DOWN @@ -8,7 +8,7 @@ public class ServiceEvent { public final Action action; - public ServiceEvent(Action action) { + public PlaybackServiceEvent(Action action) { this.action = action; } } diff --git a/event/src/main/java/de/danoeh/antennapod/event/playback/SleepTimerUpdatedEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/SleepTimerUpdatedEvent.java new file mode 100644 index 000000000..be61435a0 --- /dev/null +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/SleepTimerUpdatedEvent.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.event.playback; + +public class SleepTimerUpdatedEvent { + private static final long CANCELLED = Long.MAX_VALUE; + private final long timeLeft; + + private SleepTimerUpdatedEvent(long timeLeft) { + this.timeLeft = timeLeft; + } + + public static SleepTimerUpdatedEvent justEnabled(long timeLeft) { + return new SleepTimerUpdatedEvent(-timeLeft); + } + + public static SleepTimerUpdatedEvent updated(long timeLeft) { + return new SleepTimerUpdatedEvent(Math.max(0, timeLeft)); + } + + public static SleepTimerUpdatedEvent cancelled() { + return new SleepTimerUpdatedEvent(CANCELLED); + } + + public long getTimeLeft() { + return Math.abs(timeLeft); + } + + public boolean isOver() { + return timeLeft == 0; + } + + public boolean wasJustEnabled() { + return timeLeft < 0; + } + + public boolean isCancelled() { + return timeLeft == CANCELLED; + } +} diff --git a/event/src/main/java/de/danoeh/antennapod/event/playback/SpeedChangedEvent.java b/event/src/main/java/de/danoeh/antennapod/event/playback/SpeedChangedEvent.java new file mode 100644 index 000000000..243d20628 --- /dev/null +++ b/event/src/main/java/de/danoeh/antennapod/event/playback/SpeedChangedEvent.java @@ -0,0 +1,13 @@ +package de.danoeh.antennapod.event.playback; + +public class SpeedChangedEvent { + private final float newSpeed; + + public SpeedChangedEvent(float newSpeed) { + this.newSpeed = newSpeed; + } + + public float getNewSpeed() { + return newSpeed; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SkipIntroEndingChangedEvent.java b/event/src/main/java/de/danoeh/antennapod/event/settings/SkipIntroEndingChangedEvent.java index 583f7b13f..bb6010796 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SkipIntroEndingChangedEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/settings/SkipIntroEndingChangedEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event.settings; +package de.danoeh.antennapod.event.settings; public class SkipIntroEndingChangedEvent { private final int skipIntro; diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SpeedPresetChangedEvent.java b/event/src/main/java/de/danoeh/antennapod/event/settings/SpeedPresetChangedEvent.java index 0ac7e1316..6ca8a1290 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/SpeedPresetChangedEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/settings/SpeedPresetChangedEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event.settings; +package de.danoeh.antennapod.event.settings; public class SpeedPresetChangedEvent { private final float speed; diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/settings/VolumeAdaptionChangedEvent.java b/event/src/main/java/de/danoeh/antennapod/event/settings/VolumeAdaptionChangedEvent.java index 3905ce68f..5a2c0b63c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/event/settings/VolumeAdaptionChangedEvent.java +++ b/event/src/main/java/de/danoeh/antennapod/event/settings/VolumeAdaptionChangedEvent.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.event.settings; +package de.danoeh.antennapod.event.settings; import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting; diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java index 31d263b24..3b35fe5bd 100644 --- a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java @@ -10,18 +10,24 @@ import java.util.regex.Pattern; public class FeedFilter implements Serializable { private final String includeFilter; private final String excludeFilter; + private final int minimalDuration; public FeedFilter() { - this("", ""); + this("", "", -1); } - public FeedFilter(String includeFilter, String excludeFilter) { + public FeedFilter(String includeFilter, String excludeFilter, int minimalDuration) { // We're storing the strings and not the parsed terms because // 1. It's easier to show the user exactly what they typed in this way // (we don't have to recreate it) // 2. We don't know if we'll actually be asked to parse anything anyways. this.includeFilter = includeFilter; this.excludeFilter = excludeFilter; + this.minimalDuration = minimalDuration; + } + + public FeedFilter(String includeFilter, String excludeFilter) { + this(includeFilter, excludeFilter, -1); } /** @@ -49,11 +55,20 @@ public class FeedFilter implements Serializable { List<String> includeTerms = parseTerms(includeFilter); List<String> excludeTerms = parseTerms(excludeFilter); - if (includeTerms.size() == 0 && excludeTerms.size() == 0) { + if (includeTerms.size() == 0 && excludeTerms.size() == 0 && minimalDuration <= -1) { // nothing has been specified, so include everything return true; } + // Check if the episode is long enough if minimal duration filter is on + if (hasMinimalDurationFilter() && item.getMedia() != null) { + int durationInMs = item.getMedia().getDuration(); + // Minimal Duration is stored in seconds + if (durationInMs > 0 && durationInMs / 1000 < minimalDuration) { + return false; + } + } + // check using lowercase so the users don't have to worry about case. String title = item.getTitle().toLowerCase(Locale.getDefault()); @@ -78,6 +93,12 @@ public class FeedFilter implements Serializable { return true; } + // if they only set minimal duration filter and arrived here, autodownload + // should happen + if (hasMinimalDurationFilter()) { + return true; + } + return false; } @@ -89,6 +110,10 @@ public class FeedFilter implements Serializable { return excludeFilter; } + public int getMinimalDurationFilter() { + return minimalDuration; + } + /** * @return true if only include is set */ @@ -110,4 +135,8 @@ public class FeedFilter implements Serializable { public boolean hasExcludeFilter() { return excludeFilter.length() > 0; } + + public boolean hasMinimalDurationFilter() { + return minimalDuration > -1; + } } diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java index 460f50f88..08f79252a 100644 --- a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java @@ -64,12 +64,6 @@ public class FeedItem extends FeedComponent implements Serializable { private transient List<Chapter> chapters; private String imageUrl; - /* - * 0: auto download disabled - * 1: auto download enabled (default) - * > 1: auto download enabled, (approx.) timestamp of the last failed attempt - * where last digit denotes the number of failed attempts - */ private long autoDownload = 1; /** @@ -361,15 +355,18 @@ public class FeedItem extends FeedComponent implements Serializable { return hasChapters; } - public void setAutoDownload(boolean autoDownload) { - this.autoDownload = autoDownload ? 1 : 0; + public void disableAutoDownload() { + this.autoDownload = 0; } - public boolean getAutoDownload() { - return this.autoDownload > 0; + public long getAutoDownloadAttemptsAndTime() { + return autoDownload; } public int getFailedAutoDownloadAttempts() { + // 0: auto download disabled + // 1: auto download enabled (default) + // > 1: auto download enabled, timestamp of last failed attempt, last digit denotes number of failed attempts if (autoDownload <= 1) { return 0; } @@ -380,23 +377,33 @@ public class FeedItem extends FeedComponent implements Serializable { return failedAttempts; } - public boolean isDownloaded() { - return media != null && media.isDownloaded(); + public void increaseFailedAutoDownloadAttempts(long now) { + if (autoDownload == 0) { + return; // Don't re-enable + } + int failedAttempts = getFailedAutoDownloadAttempts() + 1; + if (failedAttempts >= 5) { + disableAutoDownload(); // giving up + } else { + autoDownload = (now / 10) * 10 + failedAttempts; + } } - public boolean isAutoDownloadable() { + public boolean isAutoDownloadable(long now) { if (media == null || media.isDownloaded() || autoDownload == 0) { return false; } if (autoDownload == 1) { - return true; + return true; // Never failed } int failedAttempts = getFailedAutoDownloadAttempts(); - double magicValue = 1.767; // 1.767^(10[=#maxNumAttempts]-1) = 168 hours / 7 days - int millisecondsInHour = 3600000; - long waitingTime = (long) (Math.pow(magicValue, failedAttempts - 1) * millisecondsInHour); - long grace = TimeUnit.MINUTES.toMillis(5); - return System.currentTimeMillis() > (autoDownload + waitingTime - grace); + long waitingTime = TimeUnit.HOURS.toMillis((long) Math.pow(2, failedAttempts - 1)); + long lastAttempt = (autoDownload / 10) * 10; + return now >= (lastAttempt + waitingTime); + } + + public boolean isDownloaded() { + return media != null && media.isDownloaded(); } /** diff --git a/net/sync/gpoddernet/build.gradle b/net/sync/gpoddernet/build.gradle index eb5af1b60..13674b5c3 100644 --- a/net/sync/gpoddernet/build.gradle +++ b/net/sync/gpoddernet/build.gradle @@ -9,4 +9,7 @@ dependencies { implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" implementation "org.apache.commons:commons-lang3:$commonslangVersion" + implementation "commons-io:commons-io:$commonsioVersion" + implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" + implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" } diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java new file mode 100644 index 000000000..ebb415248 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/HostnameParser.java @@ -0,0 +1,41 @@ +package de.danoeh.antennapod.net.sync; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class HostnameParser { + public String scheme; + public int port; + public String host; + + // split into schema, host and port - missing parts are null + private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); + + public HostnameParser(String hosturl) { + Matcher m = URLSPLIT_REGEX.matcher(hosturl); + if (m.matches()) { + scheme = m.group(1); + host = m.group(2); + if (m.group(3) == null) { + port = -1; + } else { + port = Integer.parseInt(m.group(3)); // regex -> can only be digits + } + } else { + // URL does not match regex: use it anyway -> this will cause an exception on connect + scheme = "https"; + host = hosturl; + port = 443; + } + + if (scheme == null) { // assume https + scheme = "https"; + } + + if (scheme.equals("https") && port == -1) { + port = 443; + } else if (scheme.equals("http") && port == -1) { + port = 80; + } + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java index eb18da80b..21a362a40 100644 --- a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/GpodnetService.java @@ -1,25 +1,10 @@ package de.danoeh.antennapod.net.sync.gpoddernet; import android.util.Log; + import androidx.annotation.NonNull; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; -import de.danoeh.antennapod.net.sync.model.EpisodeAction; -import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; -import de.danoeh.antennapod.net.sync.model.ISyncService; -import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; -import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; -import de.danoeh.antennapod.net.sync.model.SyncServiceException; -import de.danoeh.antennapod.net.sync.model.UploadChangesResponse; -import okhttp3.Credentials; -import okhttp3.MediaType; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import okhttp3.ResponseBody; + +import de.danoeh.antennapod.net.sync.HostnameParser; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -35,12 +20,28 @@ import java.net.URL; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedList; import java.util.List; import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetDevice; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetEpisodeActionPostResponse; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetPodcast; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetTag; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +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 okhttp3.Credentials; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; /** * Communicates with the gpodder.net service. @@ -61,43 +62,16 @@ public class GpodnetService implements ISyncService { private final OkHttpClient httpClient; - // split into schema, host and port - missing parts are null - private static final Pattern URLSPLIT_REGEX = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); - public GpodnetService(OkHttpClient httpClient, String baseHosturl, String deviceId, String username, String password) { this.httpClient = httpClient; this.deviceId = deviceId; this.username = username; this.password = password; - - Matcher m = URLSPLIT_REGEX.matcher(baseHosturl); - if (m.matches()) { - this.baseScheme = m.group(1); - this.baseHost = m.group(2); - if (m.group(3) == null) { - this.basePort = -1; - } else { - this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits - } - } else { - // URL does not match regex: use it anyway -> this will cause an exception on connect - this.baseScheme = "https"; - this.baseHost = baseHosturl; - this.basePort = 443; - } - - if (this.baseScheme == null) { // assume https - this.baseScheme = "https"; - } - - if (this.baseScheme.equals("https") && this.basePort == -1) { - this.basePort = 443; - } - - if (this.baseScheme.equals("http") && this.basePort == -1) { - this.basePort = 80; - } + HostnameParser hostname = new HostnameParser(baseHosturl == null ? DEFAULT_BASE_HOST : baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; } private void requireLoggedIn() { @@ -434,7 +408,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject changes = new JSONObject(response); - return readSubscriptionChangesFromJsonObject(changes); + return ResponseMapper.readSubscriptionChangesFromJsonObject(changes); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -515,7 +489,7 @@ public class GpodnetService implements ISyncService { String response = executeRequest(request); JSONObject json = new JSONObject(response); - return readEpisodeActionsFromJsonObject(json); + return ResponseMapper.readEpisodeActionsFromJsonObject(json); } catch (URISyntaxException e) { e.printStackTrace(); throw new IllegalStateException(e); @@ -526,7 +500,6 @@ public class GpodnetService implements ISyncService { } - /** * Logs in a specific user. This method must be called if any of the methods * that require authentication is used. @@ -615,7 +588,13 @@ public class GpodnetService implements ISyncService { e.printStackTrace(); } } - throw new GpodnetServiceBadStatusCodeException("Bad response code: " + responseCode, responseCode); + if (responseCode >= 500) { + throw new GpodnetServiceBadStatusCodeException("Gpodder.net is currently unavailable (code " + + responseCode + ")", responseCode); + } else { + throw new GpodnetServiceBadStatusCodeException("Unable to connect to Gpodder.net (code " + + responseCode + ": " + response.message() + ")", responseCode); + } } } } @@ -689,48 +668,6 @@ public class GpodnetService implements ISyncService { return new GpodnetDevice(id, caption, type, subscriptions); } - private SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) - throws JSONException { - - List<String> added = new LinkedList<>(); - JSONArray jsonAdded = object.getJSONArray("add"); - for (int i = 0; i < jsonAdded.length(); i++) { - String addedUrl = jsonAdded.getString(i); - // gpodder escapes colons unnecessarily - addedUrl = addedUrl.replace("%3A", ":"); - added.add(addedUrl); - } - - List<String> removed = new LinkedList<>(); - JSONArray jsonRemoved = object.getJSONArray("remove"); - for (int i = 0; i < jsonRemoved.length(); i++) { - String removedUrl = jsonRemoved.getString(i); - // gpodder escapes colons unnecessarily - removedUrl = removedUrl.replace("%3A", ":"); - removed.add(removedUrl); - } - - long timestamp = object.getLong("timestamp"); - return new SubscriptionChanges(added, removed, timestamp); - } - - private EpisodeActionChanges readEpisodeActionsFromJsonObject(@NonNull JSONObject object) - throws JSONException { - - List<EpisodeAction> episodeActions = new ArrayList<>(); - - long timestamp = object.getLong("timestamp"); - JSONArray jsonActions = object.getJSONArray("actions"); - for (int i = 0; i < jsonActions.length(); i++) { - JSONObject jsonAction = jsonActions.getJSONObject(i); - EpisodeAction episodeAction = EpisodeAction.readFromJsonObject(jsonAction); - if (episodeAction != null) { - episodeActions.add(episodeAction); - } - } - return new EpisodeActionChanges(episodeActions, timestamp); - } - @Override public void logout() { diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java new file mode 100644 index 000000000..c8e607d74 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/gpoddernet/mapper/ResponseMapper.java @@ -0,0 +1,60 @@ +package de.danoeh.antennapod.net.sync.gpoddernet.mapper; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +import de.danoeh.antennapod.net.sync.model.SubscriptionChanges; + +public class ResponseMapper { + + public static SubscriptionChanges readSubscriptionChangesFromJsonObject(@NonNull JSONObject object) + throws JSONException { + + List<String> added = new LinkedList<>(); + JSONArray jsonAdded = object.getJSONArray("add"); + for (int i = 0; i < jsonAdded.length(); i++) { + String addedUrl = jsonAdded.getString(i); + // gpodder escapes colons unnecessarily + addedUrl = addedUrl.replace("%3A", ":"); + added.add(addedUrl); + } + + List<String> removed = new LinkedList<>(); + JSONArray jsonRemoved = object.getJSONArray("remove"); + for (int i = 0; i < jsonRemoved.length(); i++) { + String removedUrl = jsonRemoved.getString(i); + // gpodder escapes colons unnecessarily + removedUrl = removedUrl.replace("%3A", ":"); + removed.add(removedUrl); + } + + long timestamp = object.getLong("timestamp"); + return new SubscriptionChanges(added, removed, timestamp); + } + + public static EpisodeActionChanges readEpisodeActionsFromJsonObject(@NonNull JSONObject object) + throws JSONException { + + List<EpisodeAction> episodeActions = new ArrayList<>(); + + long timestamp = object.getLong("timestamp"); + JSONArray jsonActions = object.getJSONArray("actions"); + for (int i = 0; i < jsonActions.length(); i++) { + JSONObject jsonAction = jsonActions.getJSONObject(i); + EpisodeAction episodeAction = EpisodeAction.readFromJsonObject(jsonAction); + if (episodeAction != null) { + episodeActions.add(episodeAction); + } + } + return new EpisodeActionChanges(episodeActions, timestamp); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java new file mode 100644 index 000000000..b66c44402 --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudLoginFlow.java @@ -0,0 +1,107 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import de.danoeh.antennapod.net.sync.HostnameParser; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.json.JSONException; +import org.json.JSONObject; +import android.util.Log; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.TimeUnit; + +public class NextcloudLoginFlow { + private static final String TAG = "NextcloudLoginFlow"; + + private final OkHttpClient httpClient; + private final HostnameParser hostname; + private final Context context; + private final AuthenticationCallback callback; + private String token; + private String endpoint; + private Disposable startDisposable; + private Disposable pollDisposable; + + public NextcloudLoginFlow(OkHttpClient httpClient, String hostUrl, Context context, + AuthenticationCallback callback) { + this.httpClient = httpClient; + this.hostname = new HostnameParser(hostUrl); + this.context = context; + this.callback = callback; + } + + public void start() { + startDisposable = Observable.fromCallable(() -> { + URL url = new URI(hostname.scheme, null, hostname.host, hostname.port, + "/index.php/login/v2", null, null).toURL(); + JSONObject result = doRequest(url, ""); + String loginUrl = result.getString("login"); + this.token = result.getJSONObject("poll").getString("token"); + this.endpoint = result.getJSONObject("poll").getString("endpoint"); + return loginUrl; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + result -> { + Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(result)); + context.startActivity(browserIntent); + poll(); + }, error -> { + Log.e(TAG, Log.getStackTraceString(error)); + callback.onNextcloudAuthError(error.getLocalizedMessage()); + }); + } + + private void poll() { + pollDisposable = Observable.fromCallable(() -> doRequest(URI.create(endpoint).toURL(), "token=" + token)) + .delay(1, TimeUnit.SECONDS) + .retry(60 * 10) // 10 minutes + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + callback.onNextcloudAuthenticated(result.getString("server"), + result.getString("loginName"), result.getString("appPassword")); + }, Throwable::printStackTrace); + } + + public void cancel() { + if (startDisposable != null) { + startDisposable.dispose(); + } + if (pollDisposable != null) { + pollDisposable.dispose(); + } + } + + private JSONObject doRequest(URL url, String bodyContent) throws IOException, JSONException { + RequestBody requestBody = RequestBody.create( + MediaType.get("application/x-www-form-urlencoded"), bodyContent); + Request request = new Request.Builder().url(url).method("POST", requestBody).build(); + Response response = httpClient.newCall(request).execute(); + if (response.code() != 200) { + throw new IOException("Return code " + response.code()); + } + ResponseBody body = response.body(); + return new JSONObject(body.string()); + } + + public interface AuthenticationCallback { + void onNextcloudAuthenticated(String server, String username, String password); + + void onNextcloudAuthError(String errorMessage); + } +} diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java new file mode 100644 index 000000000..647a9073c --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSyncService.java @@ -0,0 +1,169 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.HostnameParser; +import de.danoeh.antennapod.net.sync.gpoddernet.mapper.ResponseMapper; +import de.danoeh.antennapod.net.sync.gpoddernet.model.GpodnetUploadChangesResponse; +import de.danoeh.antennapod.net.sync.model.EpisodeAction; +import de.danoeh.antennapod.net.sync.model.EpisodeActionChanges; +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 okhttp3.Credentials; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.util.HashMap; +import java.util.List; + +public class NextcloudSyncService implements ISyncService { + private static final int UPLOAD_BULK_SIZE = 30; + private final OkHttpClient httpClient; + private final String baseScheme; + private final int basePort; + private final String baseHost; + private final String username; + private final String password; + + public NextcloudSyncService(OkHttpClient httpClient, String baseHosturl, + String username, String password) { + this.httpClient = httpClient; + this.username = username; + this.password = password; + HostnameParser hostname = new HostnameParser(baseHosturl); + this.baseHost = hostname.host; + this.basePort = hostname.port; + this.baseScheme = hostname.scheme; + } + + @Override + public void login() { + } + + @Override + public SubscriptionChanges getSubscriptionChanges(long lastSync) throws SyncServiceException { + try { + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscriptions"); + url.addQueryParameter("since", "" + lastSync); + String responseString = performRequest(url, "GET", null); + JSONObject json = new JSONObject(responseString); + return ResponseMapper.readSubscriptionChangesFromJsonObject(json); + } catch (JSONException | MalformedURLException e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } catch (Exception e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } + } + + @Override + public UploadChangesResponse uploadSubscriptionChanges(List<String> addedFeeds, + List<String> removedFeeds) + throws NextcloudSynchronizationServiceException { + try { + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/subscription_change/create"); + final JSONObject requestObject = new JSONObject(); + requestObject.put("add", new JSONArray(addedFeeds)); + requestObject.put("remove", new JSONArray(removedFeeds)); + RequestBody requestBody = RequestBody.create( + MediaType.get("application/json"), requestObject.toString()); + performRequest(url, "POST", requestBody); + } catch (Exception e) { + e.printStackTrace(); + throw new NextcloudSynchronizationServiceException(e); + } + + return new GpodnetUploadChangesResponse(System.currentTimeMillis() / 1000, new HashMap<>()); + } + + @Override + public EpisodeActionChanges getEpisodeActionChanges(long timestamp) throws SyncServiceException { + try { + HttpUrl.Builder uri = makeUrl("/index.php/apps/gpoddersync/episode_action"); + uri.addQueryParameter("since", "" + timestamp); + String responseString = performRequest(uri, "GET", null); + JSONObject json = new JSONObject(responseString); + return ResponseMapper.readEpisodeActionsFromJsonObject(json); + } catch (JSONException | MalformedURLException e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } catch (Exception e) { + e.printStackTrace(); + throw new SyncServiceException(e); + } + } + + @Override + public UploadChangesResponse uploadEpisodeActions(List<EpisodeAction> queuedEpisodeActions) + throws NextcloudSynchronizationServiceException { + for (int i = 0; i < queuedEpisodeActions.size(); i += UPLOAD_BULK_SIZE) { + uploadEpisodeActionsPartial(queuedEpisodeActions, + i, Math.min(queuedEpisodeActions.size(), i + UPLOAD_BULK_SIZE)); + } + return new NextcloudGpodderEpisodeActionPostResponse(System.currentTimeMillis() / 1000); + } + + private void uploadEpisodeActionsPartial(List<EpisodeAction> queuedEpisodeActions, int from, int to) + throws NextcloudSynchronizationServiceException { + try { + final JSONArray list = new JSONArray(); + for (int i = from; i < to; i++) { + EpisodeAction episodeAction = queuedEpisodeActions.get(i); + JSONObject obj = episodeAction.writeToJsonObject(); + if (obj != null) { + list.put(obj); + } + } + HttpUrl.Builder url = makeUrl("/index.php/apps/gpoddersync/episode_action/create"); + RequestBody requestBody = RequestBody.create( + MediaType.get("application/json"), list.toString()); + performRequest(url, "POST", requestBody); + } catch (Exception e) { + e.printStackTrace(); + throw new NextcloudSynchronizationServiceException(e); + } + } + + private String performRequest(HttpUrl.Builder url, String method, RequestBody body) throws IOException { + Request request = new Request.Builder() + .url(url.build()) + .header("Authorization", Credentials.basic(username, password)) + .header("Accept", "application/json") + .method(method, body) + .build(); + Response response = httpClient.newCall(request).execute(); + if (response.code() != 200) { + throw new IOException("Response code: " + response.code()); + } + return response.body().string(); + } + + private HttpUrl.Builder makeUrl(String path) { + return new HttpUrl.Builder() + .scheme(baseScheme) + .host(baseHost) + .port(basePort) + .addPathSegments(path); + } + + @Override + public void logout() { + } + + private static class NextcloudGpodderEpisodeActionPostResponse extends UploadChangesResponse { + public NextcloudGpodderEpisodeActionPostResponse(long epochSecond) { + super(epochSecond); + } + } +} + diff --git a/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java new file mode 100644 index 000000000..d907c229e --- /dev/null +++ b/net/sync/gpoddernet/src/main/java/de/danoeh/antennapod/net/sync/nextcloud/NextcloudSynchronizationServiceException.java @@ -0,0 +1,9 @@ +package de.danoeh.antennapod.net.sync.nextcloud; + +import de.danoeh.antennapod.net.sync.model.SyncServiceException; + +public class NextcloudSynchronizationServiceException extends SyncServiceException { + public NextcloudSynchronizationServiceException(Throwable e) { + super(e); + } +} diff --git a/net/sync/model/build.gradle b/net/sync/model/build.gradle index e47040892..72d962536 100644 --- a/net/sync/model/build.gradle +++ b/net/sync/model/build.gradle @@ -5,5 +5,4 @@ dependencies { implementation project(':model') annotationProcessor "androidx.annotation:annotation:$annotationVersion" - implementation "androidx.appcompat:appcompat:$appcompatVersion" } diff --git a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java index da398d83e..42fbdb310 100644 --- a/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java +++ b/net/sync/model/src/main/java/de/danoeh/antennapod/net/sync/model/EpisodeAction.java @@ -4,7 +4,6 @@ import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; -import androidx.core.util.ObjectsCompat; import org.json.JSONException; import org.json.JSONObject; @@ -13,6 +12,7 @@ import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; +import java.util.Objects; import java.util.TimeZone; import de.danoeh.antennapod.model.feed.FeedItem; @@ -159,10 +159,10 @@ public class EpisodeAction { && position == that.position && total == that.total && action != that.action - && ObjectsCompat.equals(podcast, that.podcast) - && ObjectsCompat.equals(episode, that.episode) - && ObjectsCompat.equals(timestamp, that.timestamp) - && ObjectsCompat.equals(guid, that.guid); + && Objects.equals(podcast, that.podcast) + && Objects.equals(episode, that.episode) + && Objects.equals(timestamp, that.timestamp) + && Objects.equals(guid, that.guid); } @Override diff --git a/parser/feed/build.gradle b/parser/feed/build.gradle index 4909d3fac..774e08a66 100644 --- a/parser/feed/build.gradle +++ b/parser/feed/build.gradle @@ -18,6 +18,6 @@ dependencies { implementation "commons-io:commons-io:$commonsioVersion" implementation "org.jsoup:jsoup:$jsoupVersion" - testImplementation 'junit:junit:4.13' - testImplementation 'org.robolectric:robolectric:4.5-alpha-1' + testImplementation "junit:junit:$junitVersion" + testImplementation "org.robolectric:robolectric:$robolectricVersion" } diff --git a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Itunes.java b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Itunes.java index 5f47f8377..63d8dd476 100644 --- a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Itunes.java +++ b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Itunes.java @@ -49,66 +49,33 @@ public class Itunes extends Namespace { return; } - if (AUTHOR.equals(localName)) { - parseAuthor(state); - } else if (DURATION.equals(localName)) { - parseDuration(state); - } else if (SUBTITLE.equals(localName)) { - parseSubtitle(state); - } else if (SUMMARY.equals(localName)) { - SyndElement secondElement = state.getSecondTag(); - parseSummary(state, secondElement.getName()); - } - } - - private void parseAuthor(HandlerState state) { - if (state.getFeed() != null) { - String author = state.getContentBuf().toString(); - state.getFeed().setAuthor(HtmlCompat.fromHtml(author, - HtmlCompat.FROM_HTML_MODE_LEGACY).toString()); - } - } - - private void parseDuration(HandlerState state) { - String durationStr = state.getContentBuf().toString(); - if (TextUtils.isEmpty(durationStr)) { + String content = state.getContentBuf().toString(); + String contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString(); + if (TextUtils.isEmpty(content)) { return; } - try { - long durationMs = DurationParser.inMillis(durationStr); - state.getTempObjects().put(DURATION, (int) durationMs); - } catch (NumberFormatException e) { - Log.e(NSTAG, String.format("Duration '%s' could not be parsed", durationStr)); - } - } - - private void parseSubtitle(HandlerState state) { - String subtitle = state.getContentBuf().toString(); - if (TextUtils.isEmpty(subtitle)) { - return; - } - if (state.getCurrentItem() != null) { - if (TextUtils.isEmpty(state.getCurrentItem().getDescription())) { - state.getCurrentItem().setDescriptionIfLonger(subtitle); + if (AUTHOR.equals(localName) && state.getFeed() != null) { + state.getFeed().setAuthor(contentFromHtml); + } else if (DURATION.equals(localName)) { + try { + long durationMs = DurationParser.inMillis(content); + state.getTempObjects().put(DURATION, (int) durationMs); + } catch (NumberFormatException e) { + Log.e(NSTAG, String.format("Duration '%s' could not be parsed", content)); } - } else { - if (state.getFeed() != null && TextUtils.isEmpty(state.getFeed().getDescription())) { - state.getFeed().setDescription(subtitle); + } else if (SUBTITLE.equals(localName)) { + if (state.getCurrentItem() != null && TextUtils.isEmpty(state.getCurrentItem().getDescription())) { + state.getCurrentItem().setDescriptionIfLonger(content); + } else if (state.getFeed() != null && TextUtils.isEmpty(state.getFeed().getDescription())) { + state.getFeed().setDescription(content); + } + } else if (SUMMARY.equals(localName)) { + if (state.getCurrentItem() != null) { + state.getCurrentItem().setDescriptionIfLonger(content); + } else if (Rss20.CHANNEL.equals(state.getSecondTag().getName()) && state.getFeed() != null) { + state.getFeed().setDescription(content); } - } - } - - private void parseSummary(HandlerState state, String secondElementName) { - String summary = state.getContentBuf().toString(); - if (TextUtils.isEmpty(summary)) { - return; - } - - if (state.getCurrentItem() != null) { - state.getCurrentItem().setDescriptionIfLonger(summary); - } else if (Rss20.CHANNEL.equals(secondElementName) && state.getFeed() != null) { - state.getFeed().setDescription(summary); } } } diff --git a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Rss20.java b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Rss20.java index a49cd16dd..a39e1b5b7 100644 --- a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Rss20.java +++ b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/namespace/Rss20.java @@ -3,6 +3,7 @@ package de.danoeh.antennapod.parser.feed.namespace; import android.text.TextUtils; import android.util.Log; +import androidx.core.text.HtmlCompat; import de.danoeh.antennapod.parser.feed.HandlerState; import de.danoeh.antennapod.parser.feed.element.SyndElement; import de.danoeh.antennapod.parser.feed.util.DateUtils; @@ -39,14 +40,12 @@ public class Rss20 extends Namespace { private static final String ENC_TYPE = "type"; @Override - public SyndElement handleElementStart(String localName, HandlerState state, - Attributes attributes) { - if (ITEM.equals(localName)) { + public SyndElement handleElementStart(String localName, HandlerState state, Attributes attributes) { + if (ITEM.equals(localName) && CHANNEL.equals(state.getTagstack().lastElement().getName())) { state.setCurrentItem(new FeedItem()); state.getItems().add(state.getCurrentItem()); state.getCurrentItem().setFeed(state.getFeed()); - - } else if (ENCLOSURE.equals(localName)) { + } else if (ENCLOSURE.equals(localName) && ITEM.equals(state.getTagstack().peek().getName())) { String type = attributes.getValue(ENC_TYPE); String url = attributes.getValue(ENC_URL); @@ -72,7 +71,6 @@ public class Rss20 extends Namespace { FeedMedia media = new FeedMedia(state.getCurrentItem(), url, size, type); state.getCurrentItem().setMedia(media); } - } return new SyndElement(localName, this); } @@ -100,6 +98,7 @@ public class Rss20 extends Namespace { } else if (state.getTagstack().size() >= 2 && state.getContentBuf() != null) { String contentRaw = state.getContentBuf().toString(); String content = SyndStringUtils.trimAllWhitespace(contentRaw); + String contentFromHtml = HtmlCompat.fromHtml(content, HtmlCompat.FROM_HTML_MODE_COMPACT).toString(); SyndElement topElement = state.getTagstack().peek(); String top = topElement.getName(); SyndElement secondElement = state.getSecondTag(); @@ -116,9 +115,9 @@ public class Rss20 extends Namespace { } } else if (TITLE.equals(top)) { if (ITEM.equals(second) && state.getCurrentItem() != null) { - state.getCurrentItem().setTitle(content); + state.getCurrentItem().setTitle(contentFromHtml); } else if (CHANNEL.equals(second) && state.getFeed() != null) { - state.getFeed().setTitle(content); + state.getFeed().setTitle(contentFromHtml); } } else if (LINK.equals(top)) { if (CHANNEL.equals(second) && state.getFeed() != null) { @@ -135,9 +134,9 @@ public class Rss20 extends Namespace { } } else if (DESCR.equals(localName)) { if (CHANNEL.equals(second) && state.getFeed() != null) { - state.getFeed().setDescription(content); + state.getFeed().setDescription(contentFromHtml); } else if (ITEM.equals(second) && state.getCurrentItem() != null) { - state.getCurrentItem().setDescriptionIfLonger(content); + state.getCurrentItem().setDescriptionIfLonger(contentFromHtml); } } else if (LANGUAGE.equals(localName) && state.getFeed() != null) { state.getFeed().setLanguage(content.toLowerCase(Locale.US)); diff --git a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/util/TypeGetter.java b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/util/TypeGetter.java index 12834f94f..714dbb9ac 100644 --- a/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/util/TypeGetter.java +++ b/parser/feed/src/main/java/de/danoeh/antennapod/parser/feed/util/TypeGetter.java @@ -73,7 +73,12 @@ public class TypeGetter { throw new UnsupportedFeedtypeException(Type.INVALID, tag); } } else { - eventType = xpp.next(); + try { + eventType = xpp.next(); + } catch (RuntimeException e) { + // Apparently this happens on some devices... + throw new UnsupportedFeedtypeException("Unable to get type"); + } } } } catch (XmlPullParserException e) { diff --git a/parser/feed/src/test/java/de/danoeh/antennapod/parser/feed/element/namespace/RssParserTest.java b/parser/feed/src/test/java/de/danoeh/antennapod/parser/feed/element/namespace/RssParserTest.java index 8f8942d7b..88ac5c731 100644 --- a/parser/feed/src/test/java/de/danoeh/antennapod/parser/feed/element/namespace/RssParserTest.java +++ b/parser/feed/src/test/java/de/danoeh/antennapod/parser/feed/element/namespace/RssParserTest.java @@ -96,4 +96,12 @@ public class RssParserTest { assertTrue(TextUtils.isEmpty(feed.getPaymentLinks().get(2).content)); assertEquals("https://example.com/funding3", feed.getPaymentLinks().get(2).url); } + + @Test + public void testUnsupportedElements() throws Exception { + File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testUnsupportedElements.xml"); + Feed feed = FeedParserTestHelper.runFeedParser(feedFile); + assertEquals(1, feed.getItems().size()); + assertEquals("item-0", feed.getItems().get(0).getTitle()); + } } diff --git a/parser/feed/src/test/resources/feed-rss-testUnsupportedElements.xml b/parser/feed/src/test/resources/feed-rss-testUnsupportedElements.xml new file mode 100644 index 000000000..f21ca7ebd --- /dev/null +++ b/parser/feed/src/test/resources/feed-rss-testUnsupportedElements.xml @@ -0,0 +1,14 @@ +<?xml version='1.0' encoding='UTF-8' ?> +<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"> + <channel> + <title>title</title> + <item> + <title>item-0</title> + </item> + <unsupported-element> + <item> + <title>item-1</title> + </item> + </unsupported-element> + </channel> +</rss> diff --git a/parser/media/build.gradle b/parser/media/build.gradle index c6ae6964a..106247d1d 100644 --- a/parser/media/build.gradle +++ b/parser/media/build.gradle @@ -8,5 +8,5 @@ dependencies { implementation "commons-io:commons-io:$commonsioVersion" - testImplementation 'junit:junit:4.13' + testImplementation "junit:junit:$junitVersion" } diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java deleted file mode 100644 index ed495bcf3..000000000 --- a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/OggInputStream.java +++ /dev/null @@ -1,81 +0,0 @@ -package de.danoeh.antennapod.parser.media.vorbis; - -import org.apache.commons.io.IOUtils; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Arrays; - -class OggInputStream extends InputStream { - private final InputStream input; - - /** True if OggInputStream is currently inside an Ogg page. */ - private boolean isInPage; - private long bytesLeft; - - public OggInputStream(InputStream input) { - super(); - isInPage = false; - this.input = input; - } - - @Override - public int read() throws IOException { - if (!isInPage) { - readOggPage(); - } - - if (isInPage && bytesLeft > 0) { - int result = input.read(); - bytesLeft -= 1; - if (bytesLeft == 0) { - isInPage = false; - } - return result; - } - return -1; - } - - private void readOggPage() throws IOException { - // find OggS - int[] buffer = new int[4]; - int c; - boolean isInOggS = false; - while ((c = input.read()) != -1) { - switch (c) { - case 'O': - isInOggS = true; - buffer[0] = c; - break; - case 'g': - if (buffer[1] != c) { - buffer[1] = c; - } else { - buffer[2] = c; - } - break; - case 'S': - buffer[3] = c; - break; - default: - if (isInOggS) { - Arrays.fill(buffer, 0); - isInOggS = false; - } - } - if (buffer[0] == 'O' && buffer[1] == 'g' && buffer[2] == 'g' - && buffer[3] == 'S') { - break; - } - } - // read segments - IOUtils.skipFully(input, 22); - bytesLeft = 0; - int numSegments = input.read(); - for (int i = 0; i < numSegments; i++) { - bytesLeft += input.read(); - } - isInPage = true; - } - -} diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java index f833f683b..82455d180 100644 --- a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java +++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentChapterReader.java @@ -66,11 +66,6 @@ public class VorbisCommentChapterReader extends VorbisCommentReader { } @Override - public void onNoVorbisCommentFound() { - System.out.println("No vorbis comment found"); - } - - @Override public void onEndOfComment() { System.out.println("End of comment"); for (Chapter c : chapters) { diff --git a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java index 319d3759c..b4f87bd70 100644 --- a/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java +++ b/parser/media/src/main/java/de/danoeh/antennapod/parser/media/vorbis/VorbisCommentReader.java @@ -35,43 +35,54 @@ public abstract class VorbisCommentReader { */ protected abstract void onContentVectorValue(String key, String value) throws VorbisCommentReaderException; - protected abstract void onNoVorbisCommentFound(); - protected abstract void onEndOfComment(); protected abstract void onError(VorbisCommentReaderException exception); public void readInputStream(InputStream input) throws VorbisCommentReaderException { try { - // look for identification header - if (findIdentificationHeader(input)) { - onVorbisCommentFound(); - input = new OggInputStream(input); - if (findCommentHeader(input)) { - VorbisCommentHeader commentHeader = readCommentHeader(input); - onVorbisCommentHeaderFound(commentHeader); - for (int i = 0; i < commentHeader.getUserCommentLength(); i++) { - readUserComment(input); - } - onEndOfComment(); - } else { - onError(new VorbisCommentReaderException("No comment header found")); - } - } else { - onNoVorbisCommentFound(); + findIdentificationHeader(input); + onVorbisCommentFound(); + findOggPage(input); + findCommentHeader(input); + VorbisCommentHeader commentHeader = readCommentHeader(input); + onVorbisCommentHeaderFound(commentHeader); + for (int i = 0; i < commentHeader.getUserCommentLength(); i++) { + readUserComment(input); } + onEndOfComment(); } catch (IOException e) { onError(new VorbisCommentReaderException(e)); } } + private void findOggPage(InputStream input) throws IOException { + // find OggS + byte[] buffer = new byte[4]; + final byte[] oggPageHeader = {'O', 'g', 'g', 'S'}; + for (int bytesRead = 0; bytesRead < SECOND_PAGE_MAX_LENGTH; bytesRead++) { + buffer[bytesRead % buffer.length] = (byte) input.read(); + if (bufferMatches(buffer, oggPageHeader, bytesRead)) { + break; + } + } + // read segments + IOUtils.skipFully(input, 22); + int numSegments = input.read(); + IOUtils.skipFully(input, numSegments); + } + private void readUserComment(InputStream input) throws VorbisCommentReaderException { try { long vectorLength = EndianUtils.readSwappedUnsignedInteger(input); + if (vectorLength > 20 * 1024 * 1024) { + // Avoid reading entire file if it is encoded incorrectly + throw new VorbisCommentReaderException("User comment unrealistically long: " + vectorLength); + } String key = readContentVectorKey(input, vectorLength).toLowerCase(Locale.US); boolean readValue = onContentVectorKey(key); if (readValue) { - String value = readUtf8String(input, (int) (vectorLength - key.length() - 1)); + String value = readUtf8String(input, vectorLength - key.length() - 1); onContentVectorValue(key, value); } else { IOUtils.skipFully(input, vectorLength - key.length() - 1); @@ -93,33 +104,32 @@ public abstract class VorbisCommentReader { * identification header is found, it will be skipped completely and the * method will return true, otherwise false. */ - private boolean findIdentificationHeader(InputStream input) throws IOException { + private void findIdentificationHeader(InputStream input) throws IOException { byte[] buffer = new byte[FIRST_OPUS_PAGE_LENGTH]; IOUtils.readFully(input, buffer); final byte[] oggIdentificationHeader = new byte[]{ PACKET_TYPE_IDENTIFICATION, 'v', 'o', 'r', 'b', 'i', 's' }; for (int i = 6; i < buffer.length; i++) { if (bufferMatches(buffer, oggIdentificationHeader, i)) { IOUtils.skip(input, FIRST_OGG_PAGE_LENGTH - FIRST_OPUS_PAGE_LENGTH); - return true; } else if (bufferMatches(buffer, "OpusHead".getBytes(), i)) { - return true; + return; } } - return false; + throw new IOException("No identification header found"); } - private boolean findCommentHeader(InputStream input) throws IOException { + private void findCommentHeader(InputStream input) throws IOException { byte[] buffer = new byte[64]; // Enough space for some bytes. Used circularly. final byte[] oggCommentHeader = new byte[]{ PACKET_TYPE_COMMENT, 'v', 'o', 'r', 'b', 'i', 's' }; for (int bytesRead = 0; bytesRead < SECOND_PAGE_MAX_LENGTH; bytesRead++) { buffer[bytesRead % buffer.length] = (byte) input.read(); if (bufferMatches(buffer, oggCommentHeader, bytesRead)) { - return true; + return; } else if (bufferMatches(buffer, "OpusTags".getBytes(), bytesRead)) { - return true; + return; } } - return false; + throw new IOException("No comment header found"); } /** diff --git a/playback/README.md b/playback/README.md new file mode 100644 index 000000000..0709ac2c6 --- /dev/null +++ b/playback/README.md @@ -0,0 +1,3 @@ +# :playback + +This folder contains modules that deal with media playback. diff --git a/playback/base/README.md b/playback/base/README.md new file mode 100644 index 000000000..281a799f1 --- /dev/null +++ b/playback/base/README.md @@ -0,0 +1,3 @@ +# :playback:base + +This module provides the basic interfaces for a PlaybackServiceMediaPlayer. diff --git a/playback/base/build.gradle b/playback/base/build.gradle new file mode 100644 index 000000000..a1d344492 --- /dev/null +++ b/playback/base/build.gradle @@ -0,0 +1,10 @@ +apply plugin: "com.android.library" +apply from: "../../common.gradle" + +dependencies { + implementation project(':model') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + + testImplementation "junit:junit:$junitVersion" +} diff --git a/playback/base/src/main/AndroidManifest.xml b/playback/base/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c6a44a212 --- /dev/null +++ b/playback/base/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="de.danoeh.antennapod.playback.base" /> diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java index e093383b9..d03695896 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java +++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlaybackServiceMediaPlayer.java @@ -1,10 +1,9 @@ -package de.danoeh.antennapod.core.service.playback; +package de.danoeh.antennapod.playback.base; 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; @@ -12,6 +11,7 @@ import android.view.SurfaceHolder; import java.util.List; import java.util.concurrent.Future; +import androidx.annotation.Nullable; import de.danoeh.antennapod.model.playback.MediaType; import de.danoeh.antennapod.model.playback.Playable; @@ -31,20 +31,20 @@ public abstract class PlaybackServiceMediaPlayer { /** * Return value of some PSMP methods if the method call failed. */ - static final int INVALID_TIME = -1; + public static final int INVALID_TIME = -1; private volatile PlayerStatus oldPlayerStatus; - volatile PlayerStatus playerStatus; + protected 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; + protected final PSMPCallback callback; + protected final Context context; - PlaybackServiceMediaPlayer(@NonNull Context context, + protected PlaybackServiceMediaPlayer(@NonNull Context context, @NonNull PSMPCallback callback){ this.context = context; this.callback = callback; @@ -281,7 +281,9 @@ public abstract class PlaybackServiceMediaPlayer { */ protected abstract boolean shouldLockWifi(); - final synchronized void acquireWifiLockIfNecessary() { + public abstract boolean isCasting(); + + protected final synchronized void acquireWifiLockIfNecessary() { if (shouldLockWifi()) { if (wifiLock == null) { wifiLock = ((WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE)) @@ -292,7 +294,7 @@ public abstract class PlaybackServiceMediaPlayer { } } - final synchronized void releaseWifiLockIfNecessary() { + protected final synchronized void releaseWifiLockIfNecessary() { if (wifiLock != null && wifiLock.isHeld()) { wifiLock.release(); } @@ -313,7 +315,8 @@ public abstract class PlaybackServiceMediaPlayer { * @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) { + protected 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; @@ -339,7 +342,7 @@ public abstract class PlaybackServiceMediaPlayer { /** * @see #setPlayerStatus(PlayerStatus, Playable, int) */ - final void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) { + protected final void setPlayerStatus(@NonNull PlayerStatus newStatus, Playable newMedia) { setPlayerStatus(newStatus, newMedia, INVALID_TIME); } @@ -348,16 +351,8 @@ public abstract class PlaybackServiceMediaPlayer { 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); @@ -366,7 +361,12 @@ public abstract class PlaybackServiceMediaPlayer { Playable getNextInQueue(Playable currentMedia); + @Nullable + Playable findMedia(@NonNull String url); + void onPlaybackEnded(MediaType mediaType, boolean stopPlaying); + + void ensureMediaInfoLoaded(@NonNull Playable media); } /** @@ -377,7 +377,7 @@ public abstract class PlaybackServiceMediaPlayer { public PlayerStatus playerStatus; public Playable playable; - PSMPInfo(PlayerStatus oldPlayerStatus, PlayerStatus playerStatus, Playable playable) { + public PSMPInfo(PlayerStatus oldPlayerStatus, PlayerStatus playerStatus, Playable playable) { this.oldPlayerStatus = oldPlayerStatus; this.playerStatus = playerStatus; this.playable = playable; diff --git a/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java new file mode 100644 index 000000000..d995ae21f --- /dev/null +++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/PlayerStatus.java @@ -0,0 +1,33 @@ +package de.danoeh.antennapod.playback.base; + +public enum PlayerStatus { + INDETERMINATE(0), // player is currently changing its state, listeners should wait until the state is left + 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/util/RewindAfterPauseUtils.java b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java index 813c6d0f7..7d694f38b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtils.java +++ b/playback/base/src/main/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtils.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.util; +package de.danoeh.antennapod.playback.base; import java.util.concurrent.TimeUnit; diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtilTest.java b/playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java index dc64f6ae0..b122971b2 100644 --- a/core/src/test/java/de/danoeh/antennapod/core/util/RewindAfterPauseUtilTest.java +++ b/playback/base/src/test/java/de/danoeh/antennapod/playback/base/RewindAfterPauseUtilTest.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.util; +package de.danoeh.antennapod.playback.base; import org.junit.Test; diff --git a/playback/cast/README.md b/playback/cast/README.md new file mode 100644 index 000000000..29eb8eacd --- /dev/null +++ b/playback/cast/README.md @@ -0,0 +1,3 @@ +# :playback:cast + +This module provides Chromecast support for the Google Play version of the app. diff --git a/playback/cast/build.gradle b/playback/cast/build.gradle new file mode 100644 index 000000000..c51354838 --- /dev/null +++ b/playback/cast/build.gradle @@ -0,0 +1,17 @@ +apply plugin: "com.android.library" +apply from: "../../common.gradle" +apply from: "../../playFlavor.gradle" + +dependencies { + implementation project(':event') + implementation project(':model') + implementation project(':playback:base') + + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "org.greenrobot:eventbus:$eventbusVersion" + annotationProcessor "org.greenrobot:eventbus-annotation-processor:$eventbusVersion" + + playApi 'androidx.mediarouter:mediarouter:1.2.5' + playApi 'com.google.android.gms:play-services-cast-framework:20.0.0' +} diff --git a/app/src/free/java/de/danoeh/antennapod/activity/CastEnabledActivity.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java index 98d506f65..36524b236 100644 --- a/app/src/free/java/de/danoeh/antennapod/activity/CastEnabledActivity.java +++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.activity; +package de.danoeh.antennapod.playback.cast; import androidx.appcompat.app.AppCompatActivity; diff --git a/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java new file mode 100644 index 000000000..7f5e0f2ab --- /dev/null +++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastPsmp.java @@ -0,0 +1,17 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; + +/** + * Stub implementation of CastPsmp for Free build flavour + */ +public class CastPsmp { + @Nullable + public static PlaybackServiceMediaPlayer getInstanceIfConnected(@NonNull Context context, + @NonNull PlaybackServiceMediaPlayer.PSMPCallback callback) { + return null; + } +} diff --git a/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java new file mode 100644 index 000000000..60cc7dd2c --- /dev/null +++ b/playback/cast/src/free/java/de/danoeh/antennapod/playback/cast/CastStateListener.java @@ -0,0 +1,15 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; + +public class CastStateListener { + + public CastStateListener(Context context) { + } + + public void destroy() { + } + + public void onSessionStartedOrEnded() { + } +} diff --git a/playback/cast/src/main/AndroidManifest.xml b/playback/cast/src/main/AndroidManifest.xml new file mode 100644 index 000000000..58c2b9396 --- /dev/null +++ b/playback/cast/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="de.danoeh.antennapod.playback.cast" /> diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java new file mode 100644 index 000000000..2cebde6a3 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastEnabledActivity.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.playback.cast; + +import android.os.Bundle; +import android.view.Menu; +import androidx.appcompat.app.AppCompatActivity; +import com.google.android.gms.cast.framework.CastButtonFactory; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +/** + * Activity that allows for showing the MediaRouter button whenever there's a cast device in the + * network. + */ +public abstract class CastEnabledActivity extends AppCompatActivity { + private static final String TAG = "CastEnabledActivity"; + private boolean canCast = false; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + canCast = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS; + if (canCast) { + CastContext.getSharedInstance(this); + } + } + + public void requestCastButton(Menu menu) { + if (!canCast) { + return; + } + getMenuInflater().inflate(R.menu.cast_button, menu); + CastButtonFactory.setUpMediaRouteButton(getApplicationContext(), menu, R.id.media_route_menu_item); + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java new file mode 100644 index 000000000..37885bdd0 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastOptionsProvider.java @@ -0,0 +1,26 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.google.android.gms.cast.CastMediaControlIntent; +import com.google.android.gms.cast.framework.CastOptions; +import com.google.android.gms.cast.framework.OptionsProvider; +import com.google.android.gms.cast.framework.SessionProvider; + +import java.util.List; + +@SuppressWarnings("unused") +public class CastOptionsProvider implements OptionsProvider { + @Override + @NonNull + public CastOptions getCastOptions(@NonNull Context context) { + return new CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .build(); + } + + @Override + public List<SessionProvider> getAdditionalSessionProviders(@NonNull Context context) { + return null; + } +}
\ No newline at end of file diff --git a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java index 38b469e8e..8e74154e8 100644 --- a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastPsmp.java @@ -1,66 +1,77 @@ -package de.danoeh.antennapod.core.service.playback; +package de.danoeh.antennapod.playback.cast; 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 androidx.annotation.Nullable; +import com.google.android.gms.cast.MediaError; +import com.google.android.gms.cast.MediaInfo; +import com.google.android.gms.cast.MediaLoadOptions; +import com.google.android.gms.cast.MediaLoadRequestData; +import com.google.android.gms.cast.MediaSeekOptions; +import com.google.android.gms.cast.MediaStatus; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastState; +import com.google.android.gms.cast.framework.media.RemoteMediaClient; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; +import de.danoeh.antennapod.event.PlayerErrorEvent; +import de.danoeh.antennapod.event.playback.BufferUpdateEvent; 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; +import de.danoeh.antennapod.model.playback.RemoteMedia; +import de.danoeh.antennapod.playback.base.PlaybackServiceMediaPlayer; +import de.danoeh.antennapod.playback.base.PlayerStatus; +import de.danoeh.antennapod.playback.base.RewindAfterPauseUtils; +import org.greenrobot.eventbus.EventBus; /** * Implementation of PlaybackServiceMediaPlayer suitable for remote playback on Cast Devices. */ -public class RemotePSMP extends PlaybackServiceMediaPlayer { - - public static final String TAG = "RemotePSMP"; +public class CastPsmp extends PlaybackServiceMediaPlayer { - public static final int CAST_ERROR = 3001; - - public static final int CAST_ERROR_PRIORITY_HIGH = 3005; - - private final CastManager castMgr; + public static final String TAG = "CastPSMP"; private volatile Playable media; private volatile MediaType mediaType; private volatile MediaInfo remoteMedia; private volatile int remoteState; + private final CastContext castContext; + private final RemoteMediaClient remoteMediaClient; private final AtomicBoolean isBuffering; private final AtomicBoolean startWhenPrepared; - public RemotePSMP(@NonNull Context context, @NonNull PSMPCallback callback) { + @Nullable + public static PlaybackServiceMediaPlayer getInstanceIfConnected(@NonNull Context context, + @NonNull PSMPCallback callback) { + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) { + return null; + } + if (CastContext.getSharedInstance(context).getCastState() == CastState.CONNECTED) { + return new CastPsmp(context, callback); + } else { + return null; + } + } + + public CastPsmp(@NonNull Context context, @NonNull PSMPCallback callback) { super(context, callback); - castMgr = CastManager.getInstance(); + castContext = CastContext.getSharedInstance(context); + remoteMediaClient = castContext.getSessionManager().getCurrentCastSession().getRemoteMediaClient(); + remoteMediaClient.registerCallback(remoteMediaClientCallback); media = null; mediaType = null; startWhenPrepared = new AtomicBoolean(false); @@ -68,94 +79,48 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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(); - } - + private final RemoteMediaClient.Callback remoteMediaClientCallback = new RemoteMediaClient.Callback() { @Override - public void onRemoteMediaPlayerStatusUpdated() { - RemotePSMP.this.onRemoteMediaPlayerStatusUpdated(); + public void onMetadataUpdated() { + super.onMetadataUpdated(); + 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"); - } + public void onPreloadStatusUpdated() { + super.onPreloadStatusUpdated(); + onRemoteMediaPlayerStatusUpdated(); } @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); - } + public void onStatusUpdated() { + super.onStatusUpdated(); + onRemoteMediaPlayerStatusUpdated(); } @Override - public void onFailed(int resourceId, int statusCode) { - callback.onMediaPlayerInfo(CAST_ERROR, resourceId); + public void onMediaError(@NonNull MediaError mediaError) { + EventBus.getDefault().post(new PlayerErrorEvent(mediaError.getReason())); } }; private void setBuffering(boolean buffering) { if (buffering && isBuffering.compareAndSet(false, true)) { - callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); + EventBus.getDefault().post(BufferUpdateEvent.started()); } else if (!buffering && isBuffering.compareAndSet(true, false)) { - callback.onMediaPlayerInfo(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); + EventBus.getDefault().post(BufferUpdateEvent.ended()); } } - private Playable localVersion(MediaInfo info){ - if (info == null) { + private Playable localVersion(MediaInfo info) { + if (info == null || info.getMetadata() == null) { return null; } if (CastUtils.matches(info, media)) { return media; } - return CastUtils.getPlayable(info, true); + String streamUrl = info.getMetadata().getString(CastUtils.KEY_STREAM_URL); + return streamUrl == null ? CastUtils.makeRemoteMedia(info) : callback.findMedia(streamUrl); } private MediaInfo remoteVersion(Playable playable) { @@ -166,7 +131,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { return remoteMedia; } if (playable instanceof FeedMedia) { - return CastUtils.convertFromFeedMedia((FeedMedia) playable); + return MediaInfoCreator.from((FeedMedia) playable); } if (playable instanceof RemoteMedia) { return MediaInfoCreator.from((RemoteMedia) playable); @@ -175,7 +140,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { } private void onRemoteMediaPlayerStatusUpdated() { - MediaStatus status = castMgr.getMediaStatus(); + MediaStatus status = remoteMediaClient.getMediaStatus(); if (status == null) { Log.d(TAG, "Received null MediaStatus"); return; @@ -206,8 +171,8 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { remoteState = state; } - if (mediaChanged && stateChanged && oldState == MediaStatus.PLAYER_STATE_PLAYING && - state != MediaStatus.PLAYER_STATE_IDLE) { + 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); @@ -230,9 +195,8 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { setPlayerStatus(PlayerStatus.PAUSED, currentMedia, position); break; case MediaStatus.PLAYER_STATE_BUFFERING: - setPlayerStatus((mediaChanged || playerStatus == PlayerStatus.PREPARING) ? - PlayerStatus.PREPARING : PlayerStatus.SEEKING, - currentMedia, + setPlayerStatus((mediaChanged || playerStatus == PlayerStatus.PREPARING) + ? PlayerStatus.PREPARING : PlayerStatus.SEEKING, currentMedia, currentMedia != null ? currentMedia.getPosition() : INVALID_TIME); break; case MediaStatus.PLAYER_STATE_IDLE: @@ -271,11 +235,13 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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); + Log.w(TAG, "Got an error status from the Chromecast. " + + "Skipping, if possible, to the next episode..."); + EventBus.getDefault().post(new PlayerErrorEvent("Chromecast error code 1")); endPlayback(false, false, true, true); return; + default: + return; } break; case MediaStatus.PLAYER_STATE_UNKNOWN: @@ -284,7 +250,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { } break; default: - Log.wtf(TAG, "Remote media state undetermined!"); + Log.w(TAG, "Remote media state undetermined!"); } if (mediaChanged) { callback.onMediaChanged(true); @@ -295,25 +261,29 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { } @Override - public void playMediaObject(@NonNull final Playable playable, final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + 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 + * 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)) { + private void playMediaObject(@NonNull final Playable playable, final boolean forceReset, + final boolean stream, final boolean startWhenPrepared, final boolean prepareImmediately) { + if (!CastUtils.isCastable(playable, castContext.getSessionManager().getCurrentCastSession())) { Log.d(TAG, "media provided is not compatible with cast device"); - callback.onMediaPlayerInfo(CAST_ERROR_PRIORITY_HIGH, R.string.cast_not_castable); + EventBus.getDefault().post(new PlayerErrorEvent("Media not compatible with cast device")); Playable nextPlayable = playable; do { nextPlayable = callback.getNextInQueue(nextPlayable); - } while (nextPlayable != null && !CastUtils.isCastable(nextPlayable)); + } while (nextPlayable != null && !CastUtils.isCastable(nextPlayable, + castContext.getSessionManager().getCurrentCastSession())); if (nextPlayable != null) { playMediaObject(nextPlayable, forceReset, stream, startWhenPrepared, prepareImmediately); } @@ -328,14 +298,8 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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); - } + boolean isPlaying = remoteMediaClient.isPlaying(); + int position = (int) remoteMediaClient.getApproximateStreamPosition(); if (isPlaying) { callback.onPlaybackPause(media, position); } @@ -343,7 +307,6 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { final Playable oldMedia = media; callback.onPostPlayback(oldMedia, false, false, true); } - setPlayerStatus(PlayerStatus.INDETERMINATE, null); } } @@ -353,9 +316,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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.ensureMediaInfoLoaded(media); callback.onMediaChanged(true); setPlayerStatus(PlayerStatus.INITIALIZED, media); if (prepareImmediately) { @@ -365,29 +326,16 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { @Override public void resume() { - try { - if (playerStatus == PlayerStatus.PREPARED && media.getPosition() > 0) { - int newPosition = RewindAfterPauseUtils.calculatePositionWithRewind( + 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); - } + seekTo(newPosition); + remoteMediaClient.play(); } @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); - } + remoteMediaClient.pause(); } @Override @@ -395,18 +343,16 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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); + int position = media.getPosition(); + if (position > 0) { + position = RewindAfterPauseUtils.calculatePositionWithRewind( + position, + media.getLastPlayedTime()); } + remoteMediaClient.load(new MediaLoadRequestData.Builder() + .setMediaInfo(remoteMedia) + .setAutoplay(startWhenPrepared.get()) + .setCurrentTime(position).build()); } } @@ -422,19 +368,9 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { @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); - } + new Exception("Seeking to " + t).printStackTrace(); + remoteMediaClient.seek(new MediaSeekOptions.Builder() + .setPosition(t).build()); } @Override @@ -449,49 +385,19 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { @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) { + int retVal = (int) remoteMediaClient.getStreamDuration(); + 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) { + int retVal = (int) remoteMediaClient.getApproximateStreamPosition(); + if (retVal <= 0 && media != null && media.getPosition() >= 0) { retVal = media.getPosition(); } - Log.d(TAG, "getPosition() -> " + retVal); return retVal; } @@ -507,29 +413,21 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { @Override public void setPlaybackParams(float speed, boolean skipSilence) { - //Can be safely ignored as neither set speed not skipSilence is supported + double playbackRate = (float) Math.max(MediaLoadOptions.PLAYBACK_RATE_MIN, + Math.min(MediaLoadOptions.PLAYBACK_RATE_MAX, speed)); + remoteMediaClient.setPlaybackRate(playbackRate); } @Override public float getPlaybackSpeed() { - return 1; + MediaStatus status = remoteMediaClient.getMediaStatus(); + return status != null ? (float) status.getPlaybackRate() : 1.0f; } @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); - } + remoteMediaClient.setStreamVolume(volumeLeft); } @Override @@ -554,7 +452,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { @Override public void shutdown() { - castMgr.removeCastConsumer(castConsumer); + remoteMediaClient.unregisterCallback(remoteMediaClientCallback); } @Override @@ -626,7 +524,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { boolean playNextEpisode = isPlaying && nextMedia != null; if (playNextEpisode) { Log.d(TAG, "Playback of next episode will start immediately."); - } else if (nextMedia == null){ + } else if (nextMedia == null) { Log.d(TAG, "No more episodes available to play"); } else { Log.d(TAG, "Loading next episode, but not playing automatically."); @@ -636,45 +534,34 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer { 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); + playMediaObject(nextMedia, false, true, 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) { + remoteMediaClient.stop(); // Otherwise we rely on the chromecast callback to tell us the playback has stopped. - callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, nextMedia != null); + callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, false); + } else { + callback.onPostPlayback(currentMedia, hasEnded, wasSkipped, true); } } else if (isPlaying) { callback.onPlaybackPause(currentMedia, currentMedia != null ? currentMedia.getPosition() : INVALID_TIME); } - FutureTask<?> future = new FutureTask<>(() -> {}, null); + 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; } + + @Override + public boolean isCasting() { + return true; + } } diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java new file mode 100644 index 000000000..39f54b11c --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastStateListener.java @@ -0,0 +1,69 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.Context; +import androidx.annotation.NonNull; +import com.google.android.gms.cast.framework.CastContext; +import com.google.android.gms.cast.framework.CastSession; +import com.google.android.gms.cast.framework.SessionManagerListener; +import com.google.android.gms.common.ConnectionResult; +import com.google.android.gms.common.GoogleApiAvailability; + +public class CastStateListener implements SessionManagerListener<CastSession> { + private final CastContext castContext; + + public CastStateListener(Context context) { + if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS) { + castContext = null; + return; + } + castContext = CastContext.getSharedInstance(context); + castContext.getSessionManager().addSessionManagerListener(this, CastSession.class); + } + + public void destroy() { + if (castContext != null) { + castContext.getSessionManager().removeSessionManagerListener(this, CastSession.class); + } + } + + @Override + public void onSessionStarting(@NonNull CastSession castSession) { + } + + @Override + public void onSessionStarted(@NonNull CastSession session, @NonNull String sessionId) { + onSessionStartedOrEnded(); + } + + @Override + public void onSessionStartFailed(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionEnding(@NonNull CastSession castSession) { + } + + @Override + public void onSessionResumed(@NonNull CastSession session, boolean wasSuspended) { + } + + @Override + public void onSessionResumeFailed(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionSuspended(@NonNull CastSession castSession, int i) { + } + + @Override + public void onSessionEnded(@NonNull CastSession session, int error) { + onSessionStartedOrEnded(); + } + + @Override + public void onSessionResuming(@NonNull CastSession castSession, @NonNull String s) { + } + + public void onSessionStartedOrEnded() { + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java new file mode 100644 index 000000000..312b6b2f9 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/CastUtils.java @@ -0,0 +1,181 @@ +package de.danoeh.antennapod.playback.cast; + +import android.content.ContentResolver; +import android.util.Log; +import android.text.TextUtils; +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.cast.framework.CastSession; +import com.google.android.gms.common.images.WebImage; +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 java.util.List; + +/** + * 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_STREAM_URL = "de.danoeh.antennapod.core.cast.StreamUrl"; + 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, CastSession castSession) { + if (media == null || castSession == null || castSession.getCastDevice() == 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 AUDIO: + return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_AUDIO_OUT); + case VIDEO: + return castSession.getCastDevice().hasCapability(CastDevice.CAPABILITY_VIDEO_OUT); + default: + return false; + } + } + return false; + } + + /** + * Converts {@link MediaInfo} objects into the appropriate implementation of {@link Playable}. + * @return {@link Playable} object in a format proper for casting. + */ + public static Playable makeRemoteMedia(MediaInfo media) { + 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; + } + List<WebImage> imageList = metadata.getImages(); + String imageUrl = null; + if (!imageList.isEmpty()) { + imageUrl = imageList.get(0).getUrl().toString(); + } + String notes = metadata.getString(KEY_EPISODE_NOTES); + RemoteMedia 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); + 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); + } +} diff --git a/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java new file mode 100644 index 000000000..dd408d4a7 --- /dev/null +++ b/playback/cast/src/play/java/de/danoeh/antennapod/playback/cast/MediaInfoCreator.java @@ -0,0 +1,135 @@ +package de.danoeh.antennapod.playback.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.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; +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); + metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()); + + 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(); + } + + /** + * Converts {@link FeedMedia} objects into a format suitable for sending to a Cast Device. + * Before using this method, one should make sure 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 from(FeedMedia media) { + if (media == null) { + return null; + } + MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC); + if (media.getItem() == null) { + throw new IllegalStateException("item is 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); + } + + // Manual because cast does not support embedded images + String url = feedItem.getImageUrl() == null ? feedItem.getFeed().getImageUrl() : feedItem.getImageUrl(); + if (!TextUtils.isEmpty(url)) { + metadata.addImage(new WebImage(Uri.parse(url))); + } + 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(CastUtils.KEY_FEED_URL, feed.getDownload_url()); + } + if (!TextUtils.isEmpty(feed.getLink())) { + metadata.putString(CastUtils.KEY_FEED_WEBSITE, feed.getLink()); + } + } + if (!TextUtils.isEmpty(feedItem.getItemIdentifier())) { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, feedItem.getItemIdentifier()); + } else { + metadata.putString(CastUtils.KEY_EPISODE_IDENTIFIER, media.getStreamUrl()); + } + if (!TextUtils.isEmpty(feedItem.getLink())) { + metadata.putString(CastUtils.KEY_EPISODE_LINK, feedItem.getLink()); + } + } + // 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(CastUtils.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(CastUtils.KEY_FORMAT_VERSION, CastUtils.FORMAT_VERSION_VALUE); + metadata.putString(CastUtils.KEY_STREAM_URL, media.getStreamUrl()); + + 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(); + } +} diff --git a/playback/cast/src/play/res/menu/cast_button.xml b/playback/cast/src/play/res/menu/cast_button.xml new file mode 100644 index 000000000..6e65bce18 --- /dev/null +++ b/playback/cast/src/play/res/menu/cast_button.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <item + android:id="@+id/media_route_menu_item" + android:title="" + app:actionProviderClass="androidx.mediarouter.app.MediaRouteActionProvider" + app:showAsAction="always" /> + +</menu> diff --git a/settings.gradle b/settings.gradle index f73020141..c7f5e6449 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ include ':app' include ':core' +include ':event' include ':model' include ':net:ssl' @@ -9,6 +10,9 @@ include ':net:sync:model' include ':parser:feed' include ':parser:media' +include ':playback:base' +include ':playback:cast' + include ':ui:app-start-intent' include ':ui:common' include ':ui:png-icons' diff --git a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java index 33f96f141..88c0378c1 100644 --- a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java +++ b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/MainActivityStarter.java @@ -3,6 +3,7 @@ package de.danoeh.antennapod.ui.appstartintent; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.os.Build; /** * Launches the main activity of the app with specific arguments. @@ -26,8 +27,8 @@ public class MainActivityStarter { } public PendingIntent getPendingIntent() { - return PendingIntent.getActivity(context, R.id.pending_intent_player_activity, - getIntent(), PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getActivity(context, R.id.pending_intent_player_activity, getIntent(), + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } public void start() { diff --git a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/VideoPlayerActivityStarter.java b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/VideoPlayerActivityStarter.java index 7536d34b6..53f8719de 100644 --- a/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/VideoPlayerActivityStarter.java +++ b/ui/app-start-intent/src/main/java/de/danoeh/antennapod/ui/appstartintent/VideoPlayerActivityStarter.java @@ -28,8 +28,8 @@ public class VideoPlayerActivityStarter { } public PendingIntent getPendingIntent() { - return PendingIntent.getActivity(context, R.id.pending_intent_video_player, - getIntent(), PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntent.getActivity(context, R.id.pending_intent_video_player, getIntent(), + PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } public void start() { diff --git a/ui/common/src/main/java/de/danoeh/antennapod/ui/common/ThemeUtils.java b/ui/common/src/main/java/de/danoeh/antennapod/ui/common/ThemeUtils.java index 392d09e07..12a2f6323 100644 --- a/ui/common/src/main/java/de/danoeh/antennapod/ui/common/ThemeUtils.java +++ b/ui/common/src/main/java/de/danoeh/antennapod/ui/common/ThemeUtils.java @@ -5,6 +5,7 @@ import androidx.annotation.AttrRes; import androidx.annotation.ColorInt; import android.util.TypedValue; import androidx.annotation.DrawableRes; +import androidx.core.content.ContextCompat; public class ThemeUtils { private ThemeUtils() { @@ -14,6 +15,9 @@ public class ThemeUtils { public static @ColorInt int getColorFromAttr(Context context, @AttrRes int attr) { TypedValue typedValue = new TypedValue(); context.getTheme().resolveAttribute(attr, typedValue, true); + if (typedValue.resourceId != 0) { + return ContextCompat.getColor(context, typedValue.resourceId); + } return typedValue.data; } diff --git a/ui/png-icons/src/main/res/drawable/ic_notification_cast_off.xml b/ui/png-icons/src/main/res/drawable/ic_notification_cast_off.xml deleted file mode 100644 index 3e3accd0b..000000000 --- a/ui/png-icons/src/main/res/drawable/ic_notification_cast_off.xml +++ /dev/null @@ -1,5 +0,0 @@ -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:height="30dp" android:viewportHeight="24.0" - android:viewportWidth="24.0" android:width="30dp"> - <path android:fillColor="#FFFFFFFF" android:pathData="M1.6,1.27L0.25,2.75L1.41,3.8C1.16,4.13 1,4.55 1,5V8H3V5.23L18.2,19H14V21H20.41L22.31,22.72L23.65,21.24M6.5,3L8.7,5H21V16.14L23,17.95V5C23,3.89 22.1,3 21,3M1,10V12A9,9 0 0,1 10,21H12C12,14.92 7.08,10 1,10M1,14V16A5,5 0 0,1 6,21H8A7,7 0 0,0 1,14M1,18V21H4A3,3 0 0,0 1,18Z" /> -</vector>
\ No newline at end of file |