diff options
author | brad <bradpitcher@gmail.com> | 2018-05-31 23:10:58 -0700 |
---|---|---|
committer | brad <bradpitcher@gmail.com> | 2018-05-31 23:10:58 -0700 |
commit | dc113f69b7108abd2d1a399a07788cd1c7e75692 (patch) | |
tree | a939df4d3ea3ea2ded909b37014fbcba1ed89a00 | |
parent | 41b35623eadd5d69c3751b24041f1fcfba6f2450 (diff) | |
parent | b4062146a9458aab796dea2018746d0502b66402 (diff) | |
download | AntennaPod-dc113f69b7108abd2d1a399a07788cd1c7e75692.zip |
Merge branch 'develop' into fix-2359
51 files changed, 1367 insertions, 686 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml index 256b3b882..bec559ed2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,7 +21,7 @@ jobs: - v1-android- - run: - command: ./gradlew assembleDebug -PdisablePreDex + command: ./gradlew assembleDebug :core:testPlayDebugUnitTest -PdisablePreDex no_output_timeout: 1800 - store_artifacts: 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 fb74378c7..9a5ea437c 100644 --- a/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java +++ b/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java @@ -55,6 +55,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } else { otherTheme = R.string.pref_theme_title_light; } + solo.clickOnText(solo.getString(R.string.user_interface_label)); solo.clickOnText(solo.getString(R.string.pref_set_theme_title)); solo.waitForDialogToOpen(); solo.clickOnText(solo.getString(otherTheme)); @@ -69,6 +70,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } else { otherTheme = R.string.pref_theme_title_light; } + solo.clickOnText(solo.getString(R.string.user_interface_label)); solo.clickOnText(solo.getString(R.string.pref_set_theme_title)); solo.waitForDialogToOpen(1000); solo.clickOnText(solo.getString(otherTheme)); @@ -76,6 +78,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testExpandNotification() { + solo.clickOnText(solo.getString(R.string.user_interface_label)); final int priority = UserPreferences.getNotifyPriority(); solo.clickOnText(solo.getString(R.string.pref_expandNotify_title)); assertTrue(solo.waitForCondition(() -> priority != UserPreferences.getNotifyPriority(), Timeout.getLargeTimeout())); @@ -84,7 +87,10 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEnablePersistentPlaybackControls() { + solo.clickOnText(solo.getString(R.string.user_interface_label)); final boolean persistNotify = UserPreferences.isPersistNotify(); + solo.scrollDown(); + solo.scrollDown(); solo.clickOnText(solo.getString(R.string.pref_persistNotify_title)); assertTrue(solo.waitForCondition(() -> persistNotify != UserPreferences.isPersistNotify(), Timeout.getLargeTimeout())); solo.clickOnText(solo.getString(R.string.pref_persistNotify_title)); @@ -92,6 +98,8 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testSetLockscreenButtons() { + solo.clickOnText(solo.getString(R.string.user_interface_label)); + solo.scrollDown(); String[] buttons = res.getStringArray(R.array.compact_notification_buttons_options); solo.clickOnText(solo.getString(R.string.pref_compact_notification_buttons_title)); solo.waitForDialogToOpen(1000); @@ -116,7 +124,10 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEnqueueAtFront() { + solo.clickOnText(solo.getString(R.string.playback_pref)); final boolean enqueueAtFront = UserPreferences.enqueueAtFront(); + solo.scrollDown(); + solo.scrollDown(); solo.clickOnText(solo.getString(R.string.pref_queueAddToFront_title)); assertTrue(solo.waitForCondition(() -> enqueueAtFront != UserPreferences.enqueueAtFront(), Timeout.getLargeTimeout())); solo.clickOnText(solo.getString(R.string.pref_queueAddToFront_title)); @@ -124,6 +135,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testHeadPhonesDisconnect() { + solo.clickOnText(solo.getString(R.string.playback_pref)); final boolean pauseOnHeadsetDisconnect = UserPreferences.isPauseOnHeadsetDisconnect(); solo.clickOnText(solo.getString(R.string.pref_pauseOnHeadsetDisconnect_title)); assertTrue(solo.waitForCondition(() -> pauseOnHeadsetDisconnect != UserPreferences.isPauseOnHeadsetDisconnect(), Timeout.getLargeTimeout())); @@ -132,6 +144,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testHeadPhonesReconnect() { + solo.clickOnText(solo.getString(R.string.playback_pref)); if(UserPreferences.isPauseOnHeadsetDisconnect() == false) { solo.clickOnText(solo.getString(R.string.pref_pauseOnHeadsetDisconnect_title)); assertTrue(solo.waitForCondition(UserPreferences::isPauseOnHeadsetDisconnect, Timeout.getLargeTimeout())); @@ -144,6 +157,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testBluetoothReconnect() { + solo.clickOnText(solo.getString(R.string.playback_pref)); if(UserPreferences.isPauseOnHeadsetDisconnect() == false) { solo.clickOnText(solo.getString(R.string.pref_pauseOnHeadsetDisconnect_title)); assertTrue(solo.waitForCondition(UserPreferences::isPauseOnHeadsetDisconnect, Timeout.getLargeTimeout())); @@ -156,7 +170,10 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testContinuousPlayback() { + solo.clickOnText(solo.getString(R.string.playback_pref)); final boolean continuousPlayback = UserPreferences.isFollowQueue(); + solo.scrollDown(); + solo.scrollDown(); solo.clickOnText(solo.getString(R.string.pref_followQueue_title)); assertTrue(solo.waitForCondition(() -> continuousPlayback != UserPreferences.isFollowQueue(), Timeout.getLargeTimeout())); solo.clickOnText(solo.getString(R.string.pref_followQueue_title)); @@ -164,6 +181,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testAutoDelete() { + solo.clickOnText(solo.getString(R.string.storage_pref)); final boolean autoDelete = UserPreferences.isAutoDelete(); solo.clickOnText(solo.getString(R.string.pref_auto_delete_title)); assertTrue(solo.waitForCondition(() -> autoDelete != UserPreferences.isAutoDelete(), Timeout.getLargeTimeout())); @@ -172,6 +190,9 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testPlaybackSpeeds() { + solo.clickOnText(solo.getString(R.string.playback_pref)); + solo.scrollDown(); + solo.scrollDown(); solo.clickOnText(solo.getString(R.string.pref_playback_speed_title)); solo.waitForDialogToOpen(1000); assertTrue(solo.searchText(res.getStringArray(R.array.playback_speed_values)[0])); @@ -180,6 +201,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testPauseForInterruptions() { + solo.clickOnText(solo.getString(R.string.playback_pref)); final boolean pauseForFocusLoss = UserPreferences.shouldPauseForFocusLoss(); solo.clickOnText(solo.getString(R.string.pref_pausePlaybackForFocusLoss_title)); assertTrue(solo.waitForCondition(() -> pauseForFocusLoss != UserPreferences.shouldPauseForFocusLoss(), Timeout.getLargeTimeout())); @@ -188,6 +210,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testDisableUpdateInterval() { + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_autoUpdateIntervallOrTime_sum)); solo.waitForDialogToOpen(); solo.clickOnText(solo.getString(R.string.pref_autoUpdateIntervallOrTime_Disable)); @@ -195,6 +218,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testSetUpdateInterval() { + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_autoUpdateIntervallOrTime_title)); solo.waitForDialogToOpen(); solo.clickOnText(solo.getString(R.string.pref_autoUpdateIntervallOrTime_Interval)); @@ -207,6 +231,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testMobileUpdates() { + solo.clickOnText(solo.getString(R.string.network_pref)); final boolean mobileUpdates = UserPreferences.isAllowMobileUpdate(); solo.clickOnText(solo.getString(R.string.pref_mobileUpdate_title)); assertTrue(solo.waitForCondition(() -> mobileUpdates != UserPreferences.isAllowMobileUpdate(), Timeout.getLargeTimeout())); @@ -215,6 +240,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testSetSequentialDownload() { + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_parallel_downloads_title)); solo.waitForDialogToOpen(); solo.clearEditText(0); @@ -224,6 +250,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testSetParallelDownloads() { + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_parallel_downloads_title)); solo.waitForDialogToOpen(); solo.clearEditText(0); @@ -233,6 +260,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testSetParallelDownloadsInvalidInput() { + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_parallel_downloads_title)); solo.waitForDialogToOpen(); solo.clearEditText(0); @@ -248,6 +276,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference String[] values = res.getStringArray(R.array.episode_cache_size_values); String entry = entries[entries.length/2]; final int value = Integer.valueOf(values[values.length/2]); + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.waitForText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_episode_cache_title)); @@ -261,6 +290,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference String[] values = res.getStringArray(R.array.episode_cache_size_values); String minEntry = entries[0]; final int minValue = Integer.valueOf(values[0]); + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.waitForText(solo.getString(R.string.pref_automatic_download_title)); if(!UserPreferences.isEnableAutodownload()) { @@ -278,6 +308,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference String[] values = res.getStringArray(R.array.episode_cache_size_values); String maxEntry = entries[entries.length-1]; final int maxValue = Integer.valueOf(values[values.length-1]); + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.waitForText(solo.getString(R.string.pref_automatic_download_title)); if(!UserPreferences.isEnableAutodownload()) { @@ -291,6 +322,7 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference public void testAutomaticDownload() { final boolean automaticDownload = UserPreferences.isEnableAutodownload(); + solo.clickOnText(solo.getString(R.string.network_pref)); solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.waitForText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); @@ -312,6 +344,8 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEpisodeCleanupQueueOnly() { + solo.clickOnText(solo.getString(R.string.network_pref)); + solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_episode_cleanup_title)); solo.waitForText(solo.getString(R.string.episode_cleanup_queue_removal)); solo.clickOnText(solo.getString(R.string.episode_cleanup_queue_removal)); @@ -323,6 +357,8 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEpisodeCleanupNeverAlg() { + solo.clickOnText(solo.getString(R.string.network_pref)); + solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_episode_cleanup_title)); solo.waitForText(solo.getString(R.string.episode_cleanup_never)); solo.clickOnText(solo.getString(R.string.episode_cleanup_never)); @@ -334,6 +370,8 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEpisodeCleanupClassic() { + solo.clickOnText(solo.getString(R.string.network_pref)); + solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_episode_cleanup_title)); solo.waitForText(solo.getString(R.string.episode_cleanup_after_listening)); solo.clickOnText(solo.getString(R.string.episode_cleanup_after_listening)); @@ -349,6 +387,8 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testEpisodeCleanupNumDays() { + solo.clickOnText(solo.getString(R.string.network_pref)); + solo.clickOnText(solo.getString(R.string.pref_automatic_download_title)); solo.clickOnText(solo.getString(R.string.pref_episode_cleanup_title)); solo.waitForText(solo.getString(R.string.episode_cleanup_after_listening)); solo.clickOnText("5"); @@ -368,6 +408,9 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference int seconds = UserPreferences.getRewindSecs(); int deltas[] = res.getIntArray(R.array.seek_delta_values); + solo.clickOnText(solo.getString(R.string.playback_pref)); + solo.scrollDown(); + solo.scrollDown(); solo.clickOnText(solo.getString(R.string.pref_rewind)); solo.waitForDialogToOpen(); @@ -386,6 +429,9 @@ public class PreferencesTest extends ActivityInstrumentationTestCase2<Preference } public void testFastForwardChange() { + solo.clickOnText(solo.getString(R.string.playback_pref)); + solo.scrollDown(); + solo.scrollDown(); for (int i = 2; i > 0; i--) { // repeat twice to catch any error where fastforward is tracking rewind int seconds = UserPreferences.getFastForwardSecs(); int deltas[] = res.getIntArray(R.array.seek_delta_values); diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03ba97e08..c7541cb59 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -118,12 +118,13 @@ </activity> <service - android:name=".service.PlayerWidgetService" + android:name=".core.service.PlayerWidgetJobService" + android:permission="android.permission.BIND_JOB_SERVICE" android:enabled="true" android:exported="false"> </service> - <receiver android:name=".receiver.PlayerWidget"> + <receiver android:name=".core.receiver.PlayerWidget"> <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE"/> </intent-filter> diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java index 041053a25..4d9b50073 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/AboutActivity.java @@ -21,8 +21,7 @@ import java.nio.charset.Charset; import de.danoeh.antennapod.R; import de.danoeh.antennapod.core.preferences.UserPreferences; -import rx.Observable; -import rx.Subscriber; +import rx.Single; import rx.Subscription; import rx.android.schedulers.AndroidSchedulers; import rx.schedulers.Schedulers; @@ -34,10 +33,8 @@ public class AboutActivity extends AppCompatActivity { private static final String TAG = AboutActivity.class.getSimpleName(); - private WebView webview; - private LinearLayout webviewContainer; - private int depth = 0; - + private WebView webView; + private LinearLayout webViewContainer; private Subscription subscription; @Override @@ -46,28 +43,25 @@ public class AboutActivity extends AppCompatActivity { super.onCreate(savedInstanceState); getSupportActionBar().setDisplayShowHomeEnabled(true); setContentView(R.layout.about); - webviewContainer = (LinearLayout) findViewById(R.id.webvContainer); - webview = (WebView) findViewById(R.id.webvAbout); - webview.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); + webViewContainer = (LinearLayout) findViewById(R.id.webViewContainer); + webView = (WebView) findViewById(R.id.webViewAbout); + webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); if (UserPreferences.getTheme() == R.style.Theme_AntennaPod_Dark) { - if (Build.VERSION.SDK_INT >= 11 - && Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { - webview.setLayerType(View.LAYER_TYPE_SOFTWARE, null); + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) { + webView.setLayerType(View.LAYER_TYPE_SOFTWARE, null); } - webview.setBackgroundColor(Color.TRANSPARENT); + webView.setBackgroundColor(Color.TRANSPARENT); } - webview.setWebViewClient(new WebViewClient() { + webView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - if(url.startsWith("http")) { - depth++; - return false; - } else { + if (!url.startsWith("http")) { url = url.replace("file:///android_asset/", ""); loadAsset(url); return true; } + return false; } }); @@ -75,7 +69,7 @@ public class AboutActivity extends AppCompatActivity { } private void loadAsset(String filename) { - subscription = Observable.create((Observable.OnSubscribe<String>) subscriber -> { + subscription = Single.create(subscriber -> { InputStream input = null; try { TypedArray res = AboutActivity.this.getTheme().obtainStyledAttributes( @@ -85,8 +79,7 @@ public class AboutActivity extends AppCompatActivity { res.recycle(); input = getAssets().open(filename); String webViewData = IOUtils.toString(input, Charset.defaultCharset()); - if(!webViewData.startsWith("<!DOCTYPE html>")) { - //webViewData = webViewData.replace("\n\n", "</p><p>"); + if (!webViewData.startsWith("<!DOCTYPE html>")) { webViewData = webViewData.replace("%", "%"); webViewData = "<!DOCTYPE html>" + @@ -106,35 +99,29 @@ public class AboutActivity extends AppCompatActivity { " </style>" + "</head><body><p>" + webViewData + "</p></body></html>"; webViewData = webViewData.replace("\n", "<br/>"); - depth++; - } else { - depth = 0; } webViewData = String.format(webViewData, colorString); - subscriber.onNext(webViewData); + subscriber.onSuccess(webViewData); } catch (IOException e) { + Log.e(TAG, Log.getStackTraceString(e)); subscriber.onError(e); } finally { IOUtils.closeQuietly(input); } - subscriber.onCompleted(); }) .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - webviewData -> - webview.loadDataWithBaseURL("file:///android_asset/", webviewData, "text/html", "utf-8", "about:blank"), + webViewData -> + webView.loadDataWithBaseURL("file:///android_asset/", webViewData.toString(), "text/html", "utf-8", "file:///android_asset/" + filename.toString()), error -> Log.e(TAG, Log.getStackTraceString(error)) ); } @Override public void onBackPressed() { - Log.d(TAG, "depth: " + depth); - if(depth == 1) { - loadAsset("about.html"); - } else if(depth > 1) { - webview.goBack(); + if (webView.canGoBack()) { + webView.goBack(); } else { super.onBackPressed(); } @@ -156,9 +143,9 @@ public class AboutActivity extends AppCompatActivity { if(subscription != null) { subscription.unsubscribe(); } - if (webviewContainer != null && webview != null) { - webviewContainer.removeAllViews(); - webview.destroy(); + if (webViewContainer != null && webView != null) { + webViewContainer.removeAllViews(); + webView.destroy(); } } } diff --git a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java index 123f66661..207aec20f 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/AudioplayerActivity.java @@ -13,6 +13,7 @@ import de.danoeh.antennapod.core.feed.MediaType; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.playback.ExternalMedia; +import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import de.danoeh.antennapod.dialog.VariableSpeedDialog; /** @@ -34,14 +35,13 @@ public class AudioplayerActivity extends MediaplayerInfoActivity { Log.d(TAG, "Received VIEW intent: " + intent.getData().getPath()); ExternalMedia media = new ExternalMedia(intent.getData().getPath(), MediaType.AUDIO); - Intent launchIntent = new Intent(this, PlaybackService.class); - launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); - launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, - true); - launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false); - launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, - true); - startService(launchIntent); + + new PlaybackServiceStarter(this, media) + .startWhenPrepared(true) + .shouldStream(false) + .prepareImmediately(true) + .start(); + } else if (PlaybackService.isCasting()) { Intent intent = PlaybackService.getPlayerActivityIntent(this); if (intent.getComponent() != null && diff --git a/app/src/main/java/de/danoeh/antennapod/activity/ImportExportActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/ImportExportActivity.java index 6a97adcc3..91462bce9 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/ImportExportActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/ImportExportActivity.java @@ -8,7 +8,7 @@ import android.os.Bundle; import android.os.Environment; import android.os.ParcelFileDescriptor; import android.support.design.widget.Snackbar; -import android.support.v4.content.IntentCompat; +import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.util.Log; @@ -39,7 +39,10 @@ public class ImportExportActivity extends AppCompatActivity { protected void onCreate(Bundle savedInstanceState) { setTheme(UserPreferences.getTheme()); super.onCreate(savedInstanceState); - getSupportActionBar().setDisplayShowHomeEnabled(true); + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayShowHomeEnabled(true); + } setContentView(R.layout.import_export_activity); findViewById(R.id.button_export).setOnClickListener(view -> backup()); @@ -125,7 +128,7 @@ public class ImportExportActivity extends AppCompatActivity { d.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { Intent intent = new Intent(getApplicationContext(), SplashActivity.class); ComponentName cn = intent.getComponent(); - Intent mainIntent = IntentCompat.makeRestartActivityTask(cn); + Intent mainIntent = Intent.makeRestartActivityTask(cn); startActivity(mainIntent); }); d.show(); 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 e593934a9..294ab5af8 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -30,6 +30,8 @@ import android.widget.ListView; import com.bumptech.glide.Glide; +import de.danoeh.antennapod.core.event.ServiceEvent; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.Validate; @@ -200,6 +202,8 @@ public class MainActivity extends CastEnabledActivity implements NavDrawerActivi transaction.commit(); checkFirstLaunch(); + NotificationUtils.createChannels(this); + UserPreferences.restartUpdateAlarm(false); } private void saveLastNavFragment(String tag) { @@ -739,6 +743,15 @@ public class MainActivity extends CastEnabledActivity implements NavDrawerActivi loadData(); } + public void onEventMainThread(ServiceEvent event) { + Log.d(TAG, "onEvent(" + event + ")"); + switch(event.action) { + case SERVICE_STARTED: + externalPlayerFragment.connectToPlaybackService(); + break; + } + } + public void onEventMainThread(ProgressEvent event) { Log.d(TAG, "onEvent(" + event + ")"); switch(event.action) { diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java index 35f9579df..091f8daab 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MediaplayerActivity.java @@ -34,6 +34,7 @@ import com.joanzapata.iconify.fonts.FontAwesomeIcons; import java.util.Locale; import de.danoeh.antennapod.R; +import de.danoeh.antennapod.core.event.ServiceEvent; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -271,6 +272,9 @@ public abstract class MediaplayerActivity extends CastEnabledActivity implements controller.release(); } controller = newPlaybackController(); + setupGUI(); + loadMediaInfo(); + onPositionObserverUpdate(); } @Override @@ -620,11 +624,21 @@ public abstract class MediaplayerActivity extends CastEnabledActivity implements super.onResume(); Log.d(TAG, "onResume()"); StorageUtils.checkStorageAvailability(this); - if(controller != null) { + if (controller != null) { controller.init(); } } + public void onEventMainThread(ServiceEvent event) { + Log.d(TAG, "onEvent(" + event + ")"); + if (event.action == ServiceEvent.Action.SERVICE_STARTED) { + if (controller != null) { + controller.init(); + } + + } + } + /** * Called by 'handleStatus()' when the PlaybackService is waiting for * a video surface. @@ -865,6 +879,7 @@ public abstract class MediaplayerActivity extends CastEnabledActivity implements if(controller == null) { return; } + controller.init(); controller.playPause(); } 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 733f39b63..c8fb12abc 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/VideoplayerActivity.java @@ -30,6 +30,7 @@ import de.danoeh.antennapod.core.service.playback.PlayerStatus; import de.danoeh.antennapod.core.util.gui.PictureInPictureUtil; import de.danoeh.antennapod.core.util.playback.ExternalMedia; import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import de.danoeh.antennapod.view.AspectRatioVideoView; import java.lang.ref.WeakReference; @@ -82,14 +83,12 @@ public class VideoplayerActivity extends MediaplayerActivity { Log.d(TAG, "Received VIEW intent: " + intent.getData().getPath()); ExternalMedia media = new ExternalMedia(intent.getData().getPath(), MediaType.VIDEO); - Intent launchIntent = new Intent(this, PlaybackService.class); - launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); - launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, - true); - launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, false); - launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, - true); - startService(launchIntent); + + new PlaybackServiceStarter(this, media) + .startWhenPrepared(true) + .shouldStream(false) + .prepareImmediately(true) + .start(); } else if (PlaybackService.isCasting()) { Intent intent = PlaybackService.getPlayerActivityIntent(this); if (!intent.getComponent().getClassName().equals(VideoplayerActivity.class.getName())) { diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java index 4a53be9dc..d8f324e8a 100644 --- a/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java +++ b/app/src/main/java/de/danoeh/antennapod/adapter/DefaultActionButtonCallback.java @@ -6,6 +6,7 @@ import android.widget.Toast; import com.afollestad.materialdialogs.MaterialDialog; +import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import org.apache.commons.lang3.Validate; import de.danoeh.antennapod.R; @@ -80,13 +81,19 @@ public class DefaultActionButtonCallback implements ActionButtonCallback { Toast.makeText(context, R.string.download_canceled_msg, Toast.LENGTH_LONG).show(); } } else { // media is downloaded - if (item.hasMedia() && item.getMedia().isCurrentlyPlaying()) { + if (media.isCurrentlyPlaying()) { + new PlaybackServiceStarter(context, media) + .startWhenPrepared(true) + .shouldStream(false) + .start(); context.sendBroadcast(new Intent(PlaybackService.ACTION_PAUSE_PLAY_CURRENT_EPISODE)); - } - else if (item.hasMedia() && item.getMedia().isCurrentlyPaused()) { + } else if (media.isCurrentlyPaused()) { + new PlaybackServiceStarter(context, media) + .startWhenPrepared(true) + .shouldStream(false) + .start(); context.sendBroadcast(new Intent(PlaybackService.ACTION_RESUME_PLAY_CURRENT_EPISODE)); - } - else { + } else { DBTasks.playMedia(context, media, false, true, false); } } 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 2705e7402..b072aeaf2 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ExternalPlayerFragment.java @@ -37,7 +37,6 @@ public class ExternalPlayerFragment extends Fragment { private ImageButton butPlay; private TextView mFeedName; private ProgressBar mProgressBar; - private PlaybackController controller; public ExternalPlayerFragment() { @@ -83,6 +82,11 @@ public class ExternalPlayerFragment extends Fragment { controller.playPause(); } }); + loadMediaInfo(); + } + + public void connectToPlaybackService() { + controller.init(); } private PlaybackController setupPlaybackController() { @@ -164,36 +168,35 @@ public class ExternalPlayerFragment extends Fragment { private boolean loadMediaInfo() { Log.d(TAG, "Loading media info"); - if (controller != null && controller.serviceAvailable()) { - Playable media = controller.getMedia(); - if (media != null) { - txtvTitle.setText(media.getEpisodeTitle()); - mFeedName.setText(media.getFeedTitle()); - mProgressBar.setProgress((int) - ((double) controller.getPosition() / controller.getDuration() * 100)); - - Glide.with(getActivity()) - .load(media.getImageLocation()) - .placeholder(R.color.light_gray) - .error(R.color.light_gray) - .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) - .fitCenter() - .dontAnimate() - .into(imgvCover); - - fragmentLayout.setVisibility(View.VISIBLE); - if (controller.isPlayingVideoLocally()) { - butPlay.setVisibility(View.GONE); - } else { - butPlay.setVisibility(View.VISIBLE); - } - return true; + if (controller == null) { + Log.w(TAG, "loadMediaInfo was called while PlaybackController was null!"); + return false; + } + + Playable media = controller.getMedia(); + if (media != null) { + txtvTitle.setText(media.getEpisodeTitle()); + mFeedName.setText(media.getFeedTitle()); + onPositionObserverUpdate(); + + Glide.with(getActivity()) + .load(media.getImageLocation()) + .placeholder(R.color.light_gray) + .error(R.color.light_gray) + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .fitCenter() + .dontAnimate() + .into(imgvCover); + + fragmentLayout.setVisibility(View.VISIBLE); + if (controller.isPlayingVideoLocally()) { + butPlay.setVisibility(View.GONE); } else { - Log.w(TAG, "loadMediaInfo was called while the media object of playbackService was null!"); - return false; + butPlay.setVisibility(View.VISIBLE); } + return true; } else { - Log.w(TAG, "loadMediaInfo was called while playbackService was null!"); + Log.w(TAG, "loadMediaInfo was called while the media object of playbackService was null!"); return false; } } 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 2aa97977e..6b589493b 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java @@ -34,6 +34,7 @@ import com.bumptech.glide.Glide; import com.joanzapata.iconify.Iconify; import com.joanzapata.iconify.widget.IconButton; +import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.NetworkUtils; import org.apache.commons.lang3.ArrayUtils; diff --git a/app/src/main/java/de/danoeh/antennapod/service/PlayerWidgetService.java b/app/src/main/java/de/danoeh/antennapod/service/PlayerWidgetService.java deleted file mode 100644 index 27203d0c9..000000000 --- a/app/src/main/java/de/danoeh/antennapod/service/PlayerWidgetService.java +++ /dev/null @@ -1,265 +0,0 @@ -package de.danoeh.antennapod.service; - -import android.app.PendingIntent; -import android.app.Service; -import android.appwidget.AppWidgetManager; -import android.content.ComponentName; -import android.content.Intent; -import android.content.ServiceConnection; -import android.graphics.Bitmap; -import android.os.Build; -import android.os.IBinder; -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.widget.RemoteViews; - -import com.bumptech.glide.Glide; - -import de.danoeh.antennapod.R; -import de.danoeh.antennapod.activity.MainActivity; -import de.danoeh.antennapod.core.feed.FeedItem; -import de.danoeh.antennapod.core.feed.FeedMedia; -import de.danoeh.antennapod.core.glide.ApGlideSettings; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; -import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; -import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.core.util.Converter; -import de.danoeh.antennapod.core.util.playback.Playable; -import de.danoeh.antennapod.fragment.QueueFragment; -import de.danoeh.antennapod.receiver.PlayerWidget; - -/** - * Updates the state of the player widget - */ -public class PlayerWidgetService extends Service { - private static final String TAG = "PlayerWidgetService"; - - private PlaybackService playbackService; - - /** - * Controls write access to playbackservice reference - */ - private final Object psLock = new Object(); - - /** - * True while service is updating the widget - */ - private volatile boolean isUpdating; - - public PlayerWidgetService() { - } - - @Override - public void onCreate() { - super.onCreate(); - Log.d(TAG, "Service created"); - isUpdating = false; - } - - @Override - public void onDestroy() { - super.onDestroy(); - Log.d(TAG, "Service is about to be destroyed"); - if (playbackService != null) { - Playable playable = playbackService.getPlayable(); - if (playable != null && playable instanceof FeedMedia) { - FeedMedia media = (FeedMedia) playable; - if (media.hasAlmostEnded()) { - Log.d(TAG, "smart mark as read"); - FeedItem item = media.getItem(); - DBWriter.markItemPlayed(item, FeedItem.PLAYED, false); - DBWriter.removeQueueItem(this, item, false); - DBWriter.addItemToPlaybackHistory(media); - if (item.getFeed().getPreferences().getCurrentAutoDelete() && - (!item.isTagged(FeedItem.TAG_FAVORITE) || !UserPreferences.shouldFavoriteKeepEpisode())) { - Log.d(TAG, "Delete " + media.toString()); - DBWriter.deleteFeedMediaOfItem(this, media.getId()); - } - } - } - } - - try { - unbindService(mConnection); - } catch (IllegalArgumentException e) { - Log.w(TAG, "IllegalArgumentException when trying to unbind service"); - } - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (!isUpdating) { - if (playbackService == null && PlaybackService.isRunning) { - bindService(new Intent(this, PlaybackService.class), - mConnection, 0); - } else { - startViewUpdaterIfNotRunning(); - } - } else { - Log.d(TAG, "Service was called while updating. Ignoring update request"); - } - return Service.START_NOT_STICKY; - } - - private void updateViews() { - isUpdating = true; - - ComponentName playerWidget = new ComponentName(this, PlayerWidget.class); - AppWidgetManager manager = AppWidgetManager.getInstance(this); - RemoteViews views = new RemoteViews(getPackageName(), - R.layout.player_widget); - PendingIntent startMediaplayer = PendingIntent.getActivity(this, 0, - PlaybackService.getPlayerActivityIntent(this), 0); - - Intent startApp = new Intent(getBaseContext(), MainActivity.class); - startApp.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startApp.putExtra(MainActivity.EXTRA_FRAGMENT_TAG, QueueFragment.TAG); - PendingIntent startAppPending = PendingIntent.getActivity(getBaseContext(), 0, startApp, PendingIntent.FLAG_UPDATE_CURRENT); - - boolean nothingPlaying = false; - if (playbackService != null) { - final Playable media = playbackService.getPlayable(); - if (media != null) { - PlayerStatus status = playbackService.getStatus(); - views.setOnClickPendingIntent(R.id.layout_left, startMediaplayer); - - try { - Bitmap icon = null; - int iconSize = getResources().getDimensionPixelSize( - android.R.dimen.app_icon_size); - icon = Glide.with(PlayerWidgetService.this) - .load(media.getImageLocation()) - .asBitmap() - .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) - .centerCrop() - .into(iconSize, iconSize) - .get(); - views.setImageViewBitmap(R.id.imgvCover, icon); - } catch (Throwable tr) { - Log.e(TAG, "Error loading the media icon for the widget", tr); - } - - views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle()); - - String progressString = getProgressString(); - if (progressString != null) { - views.setViewVisibility(R.id.txtvProgress, View.VISIBLE); - views.setTextViewText(R.id.txtvProgress, progressString); - } - - if (status == PlayerStatus.PLAYING) { - views.setImageViewResource(R.id.butPlay, R.drawable.ic_pause_white_24dp); - if (Build.VERSION.SDK_INT >= 15) { - views.setContentDescription(R.id.butPlay, getString(R.string.pause_label)); - } - } else { - views.setImageViewResource(R.id.butPlay, R.drawable.ic_play_arrow_white_24dp); - if (Build.VERSION.SDK_INT >= 15) { - views.setContentDescription(R.id.butPlay, getString(R.string.play_label)); - } - } - views.setOnClickPendingIntent(R.id.butPlay, - createMediaButtonIntent()); - } else { - nothingPlaying = true; - } - } else { - nothingPlaying = true; - } - - if (nothingPlaying) { - // start the app if they click anything - views.setOnClickPendingIntent(R.id.layout_left, startAppPending); - views.setOnClickPendingIntent(R.id.butPlay, startAppPending); - views.setViewVisibility(R.id.txtvProgress, View.INVISIBLE); - views.setImageViewResource(R.id.imgvCover, R.drawable.ic_stat_antenna_default); - views.setTextViewText(R.id.txtvTitle, - this.getString(R.string.no_media_playing_label)); - views.setImageViewResource(R.id.butPlay, R.drawable.ic_play_arrow_white_24dp); - } - - manager.updateAppWidget(playerWidget, views); - isUpdating = false; - } - - /** - * Creates an intent which fakes a mediabutton press - */ - private PendingIntent createMediaButtonIntent() { - KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, - KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); - Intent startingIntent = new Intent( - MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); - startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); - - return PendingIntent.getBroadcast(this, 0, startingIntent, 0); - } - - private String getProgressString() { - int position = playbackService.getCurrentPosition(); - int duration = playbackService.getDuration(); - if (position > 0 && duration > 0) { - return Converter.getDurationStringLong(position) + " / " - + Converter.getDurationStringLong(duration); - } else { - return null; - } - } - - private final ServiceConnection mConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder service) { - Log.d(TAG, "Connection to service established"); - synchronized (psLock) { - if(service instanceof PlaybackService.LocalBinder) { - playbackService = ((PlaybackService.LocalBinder) service).getService(); - startViewUpdaterIfNotRunning(); - } - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - synchronized (psLock) { - playbackService = null; - Log.d(TAG, "Disconnected from service"); - } - } - - }; - - private void startViewUpdaterIfNotRunning() { - if (!isUpdating) { - ViewUpdater updateThread = new ViewUpdater(this); - updateThread.start(); - } - } - - class ViewUpdater extends Thread { - private static final String THREAD_NAME = "ViewUpdater"; - private final PlayerWidgetService service; - - public ViewUpdater(PlayerWidgetService service) { - super(); - setName(THREAD_NAME); - this.service = service; - - } - - @Override - public void run() { - synchronized (psLock) { - service.updateViews(); - } - } - - } - -} diff --git a/app/src/main/res/layout/about.xml b/app/src/main/res/layout/about.xml index 02e232d9a..42f5e1245 100644 --- a/app/src/main/res/layout/about.xml +++ b/app/src/main/res/layout/about.xml @@ -1,12 +1,12 @@ <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:id="@+id/webvContainer" + android:id="@+id/webViewContainer" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" > <WebView - android:id="@+id/webvAbout" + android:id="@+id/webViewAbout" android:layout_width="match_parent" android:layout_height="match_parent" /> diff --git a/app/src/main/res/layout/external_player_fragment.xml b/app/src/main/res/layout/external_player_fragment.xml index 0efee08db..dc890807c 100644 --- a/app/src/main/res/layout/external_player_fragment.xml +++ b/app/src/main/res/layout/external_player_fragment.xml @@ -44,6 +44,7 @@ android:layout_centerVertical="true" android:contentDescription="@string/pause_label" android:background="?attr/selectableItemBackground" + android:src="?attr/av_play_big" tools:src="@drawable/ic_play_arrow_white_36dp"/> <TextView diff --git a/app/src/main/res/layout/mediaplayerinfo_activity.xml b/app/src/main/res/layout/mediaplayerinfo_activity.xml index 64738eb45..21c4940b5 100644 --- a/app/src/main/res/layout/mediaplayerinfo_activity.xml +++ b/app/src/main/res/layout/mediaplayerinfo_activity.xml @@ -110,9 +110,9 @@ android:layout_centerHorizontal="true" android:background="?attr/selectableItemBackground" android:contentDescription="@string/pause_label" - android:src="?attr/av_pause" + android:src="?attr/av_play" android:scaleType="fitCenter" - tools:src="@drawable/ic_pause_white_36dp" + tools:src="@drawable/ic_play_arrow_white_24dp" tools:background="@android:color/holo_green_dark" /> <ImageButton diff --git a/build.gradle b/build.gradle index e0ad11e6c..bc1bb870d 100644 --- a/build.gradle +++ b/build.gradle @@ -66,7 +66,7 @@ project.ext { castCompanionLibVer = "2.9.1" playServicesVersion = "8.4.0" - wearableSupportVersion = "2.0.3" + wearableSupportVersion = "2.2.0" } task wrapper(type: Wrapper) { diff --git a/core/build.gradle b/core/build.gradle index de156d7c7..eb857269a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -10,7 +10,7 @@ android { versionCode 1 versionName "1.0" testApplicationId "de.danoeh.antennapod.core.tests" - testInstrumentationRunner "de.danoeh.antennapod.core.tests.AntennaPodTestRunner" + testInstrumentationRunner "de.danoeh.antennapod.core.AntennaPodTestRunner" } buildTypes { release { @@ -79,6 +79,18 @@ dependencies { } else { System.out.println("core: free build hack, skipping some dependencies") } + + testImplementation 'junit:junit:4.12' + +} + +tasks.withType(Test) { + testLogging { + exceptionFormat "full" + events "skipped", "passed", "failed" + showStandardStreams true + displayGranularity 2 + } } allprojects { diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/AntennaPodTestRunner.java b/core/src/androidTest/java/de/danoeh/antennapod/core/AntennaPodTestRunner.java index 78e854b41..5d086c054 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/AntennaPodTestRunner.java +++ b/core/src/androidTest/java/de/danoeh/antennapod/core/AntennaPodTestRunner.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.tests; +package de.danoeh.antennapod.core; import android.test.InstrumentationTestRunner; import android.test.suitebuilder.TestSuiteBuilder; diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/DateUtilsTest.java b/core/src/androidTest/java/de/danoeh/antennapod/core/util/DateUtilsTest.java index ee90d9116..d5efdbc24 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/DateUtilsTest.java +++ b/core/src/androidTest/java/de/danoeh/antennapod/core/util/DateUtilsTest.java @@ -1,4 +1,4 @@ -package de.danoeh.antennapod.core.tests.util; +package de.danoeh.antennapod.core.util; import android.test.AndroidTestCase; @@ -7,8 +7,14 @@ import java.util.Date; import java.util.GregorianCalendar; import java.util.TimeZone; -import de.danoeh.antennapod.core.util.DateUtils; - +/** + * Unit test for {@link DateUtils}. + * + * Note: It NEEDS to be run in android devices, i.e., it cannot be run in standard JDK, because + * the test invokes some android platform-specific behavior in the underlying + * {@link java.text.SimpleDateFormat} used by {@link DateUtils}. + * + */ public class DateUtilsTest extends AndroidTestCase { public void testParseDateWithMicroseconds() throws Exception { @@ -101,6 +107,12 @@ public class DateUtilsTest extends AndroidTestCase { assertEquals(expected, actual); } + /** + * Requires Android platform. + * + * Reason: Standard JDK cannot parse timezone <code>-08:00</code> (ISO 8601 format). It only accepts + * <code>-0800</code> (RFC 822 format) + */ public void testParseDateWithNoTimezonePadding() throws Exception { GregorianCalendar exp = new GregorianCalendar(2017, 1, 22, 22, 28, 0); exp.setTimeZone(TimeZone.getTimeZone("UTC")); @@ -109,6 +121,12 @@ public class DateUtilsTest extends AndroidTestCase { assertEquals(expected, actual); } + /** + * Requires Android platform. Root cause: {@link DateUtils} implementation makes + * use of ISO 8601 time zone, which does not work on standard JDK. + * + * @see #testParseDateWithNoTimezonePadding() + */ public void testParseDateWithForCest() throws Exception { GregorianCalendar exp1 = new GregorianCalendar(2017, 0, 28, 22, 0, 0); exp1.setTimeZone(TimeZone.getTimeZone("UTC")); diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 520139375..1146f2a87 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ </service> <service android:name=".service.GpodnetSyncService" + android:permission="android.permission.BIND_JOB_SERVICE" android:enabled="true" /> <receiver @@ -57,6 +58,12 @@ android:exported="true"> <!-- allow feeds update to be triggered by external apps --> </receiver> + <service + android:name=".service.FeedUpdateJobService" + android:permission="android.permission.BIND_JOB_SERVICE" > + + </service> + </application> </manifest> diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java index 5bd65f4e9..f4c99011a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/FlattrClickWorker.java @@ -10,6 +10,7 @@ import android.support.v4.app.NotificationCompat; import android.util.Log; import android.widget.Toast; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; import org.shredzone.flattr4j.exception.FlattrException; import java.util.LinkedList; @@ -175,7 +176,7 @@ public class FlattrClickWorker extends AsyncTask<Void, Integer, FlattrClickWorke PendingIntent contentIntent = PendingIntent.getActivity(context, 0, ClientConfig.flattrCallbacks.getFlattrAuthenticationActivityIntent(context), 0); - Notification notification = new NotificationCompat.Builder(context) + Notification notification = new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_ERROR) .setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(R.string.no_flattr_token_notification_msg))) .setContentIntent(contentIntent) .setContentTitle(context.getString(R.string.no_flattr_token_title)) @@ -208,7 +209,7 @@ public class FlattrClickWorker extends AsyncTask<Void, Integer, FlattrClickWorke + context.getString(R.string.flattr_click_failure_count, failed); } - Notification notification = new NotificationCompat.Builder(context) + Notification notification = new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_ERROR) .setStyle(new NotificationCompat.BigTextStyle().bigText(subtext)) .setContentIntent(contentIntent) .setContentTitle(title) diff --git a/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java b/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java new file mode 100644 index 000000000..b3241a8b6 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/event/ServiceEvent.java @@ -0,0 +1,13 @@ +package de.danoeh.antennapod.core.event; + +public class ServiceEvent { + public enum Action { + SERVICE_STARTED + } + + public final Action action; + + public ServiceEvent(Action action) { + this.action = action; + } +} 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 a93012d59..44b2fa2b2 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 @@ -1,18 +1,21 @@ package de.danoeh.antennapod.core.preferences; -import android.app.AlarmManager; -import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; -import android.os.SystemClock; import android.preference.PreferenceManager; import android.support.annotation.IntRange; import android.support.annotation.NonNull; import android.support.v4.app.NotificationCompat; import android.text.TextUtils; import android.util.Log; - +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.service.download.ProxyConfig; +import de.danoeh.antennapod.core.storage.APCleanupAlgorithm; +import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm; +import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm; +import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.core.util.download.AutoUpdateManager; import org.json.JSONArray; import org.json.JSONException; @@ -21,19 +24,9 @@ import java.io.IOException; import java.net.Proxy; import java.util.ArrayList; import java.util.Arrays; -import java.util.Calendar; import java.util.List; import java.util.concurrent.TimeUnit; -import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.receiver.FeedUpdateReceiver; -import de.danoeh.antennapod.core.service.download.ProxyConfig; -import de.danoeh.antennapod.core.storage.APCleanupAlgorithm; -import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm; -import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm; -import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm; -import de.danoeh.antennapod.core.util.Converter; - /** * Provides access to preferences set by the user in the settings screen. A * private instance of this class must first be instantiated via @@ -781,58 +774,15 @@ public class UserPreferences { int[] timeOfDay = getUpdateTimeOfDay(); Log.d(TAG, "timeOfDay: " + Arrays.toString(timeOfDay)); if (timeOfDay.length == 2) { - restartUpdateTimeOfDayAlarm(timeOfDay[0], timeOfDay[1]); + AutoUpdateManager.restartUpdateTimeOfDayAlarm(context, timeOfDay[0], timeOfDay[1]); } else { long milliseconds = getUpdateInterval(); long startTrigger = milliseconds; if (now) { startTrigger = TimeUnit.SECONDS.toMillis(10); } - restartUpdateIntervalAlarm(startTrigger, milliseconds); - } - } - - /** - * Sets the interval in which the feeds are refreshed automatically - */ - private static void restartUpdateIntervalAlarm(long triggerAtMillis, long intervalMillis) { - Log.d(TAG, "Restarting update alarm."); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - Intent intent = new Intent(context, FeedUpdateReceiver.class); - PendingIntent updateIntent = PendingIntent.getBroadcast(context, 0, intent, 0); - alarmManager.cancel(updateIntent); - if (intervalMillis > 0) { - alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, - SystemClock.elapsedRealtime() + triggerAtMillis, - updateIntent); - Log.d(TAG, "Changed alarm to new interval " + TimeUnit.MILLISECONDS.toHours(intervalMillis) + " h"); - } else { - Log.d(TAG, "Automatic update was deactivated"); - } - } - - /** - * Sets time of day the feeds are refreshed automatically - */ - private static void restartUpdateTimeOfDayAlarm(int hoursOfDay, int minute) { - Log.d(TAG, "Restarting update alarm."); - AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - PendingIntent updateIntent = PendingIntent.getBroadcast(context, 0, - new Intent(context, FeedUpdateReceiver.class), 0); - alarmManager.cancel(updateIntent); - - Calendar now = Calendar.getInstance(); - Calendar alarm = (Calendar)now.clone(); - alarm.set(Calendar.HOUR_OF_DAY, hoursOfDay); - alarm.set(Calendar.MINUTE, minute); - if (alarm.before(now) || alarm.equals(now)) { - alarm.add(Calendar.DATE, 1); + AutoUpdateManager.restartUpdateIntervalAlarm(context, startTrigger, milliseconds); } - Log.d(TAG, "Alarm set for: " + alarm.toString() + " : " + alarm.getTimeInMillis()); - alarmManager.set(AlarmManager.RTC_WAKEUP, - alarm.getTimeInMillis(), - updateIntent); - Log.d(TAG, "Changed alarm to new time of day " + hoursOfDay + ":" + minute); } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java index 9bbeb7c88..05e12f6df 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/FeedUpdateReceiver.java @@ -7,8 +7,7 @@ import android.util.Log; import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.storage.DBTasks; -import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.core.util.FeedUpdateUtils; /** * Refreshes all feeds when it receives an intent @@ -21,11 +20,7 @@ public class FeedUpdateReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { Log.d(TAG, "Received intent"); ClientConfig.initialize(context); - if (NetworkUtils.networkAvailable() && NetworkUtils.isDownloadAllowed()) { - DBTasks.refreshAllFeeds(context, null); - } else { - Log.d(TAG, "Blocking automatic update: no wifi available / no mobile updates allowed"); - } + FeedUpdateUtils.startAutoUpdate(context, null); UserPreferences.restartUpdateAlarm(false); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java index 9b4b91151..b191dbf8b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/MediaButtonReceiver.java @@ -3,6 +3,7 @@ package de.danoeh.antennapod.core.receiver; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.support.v4.content.ContextCompat; import android.util.Log; import android.view.KeyEvent; @@ -29,7 +30,7 @@ public class MediaButtonReceiver extends BroadcastReceiver { Intent serviceIntent = new Intent(context, PlaybackService.class); serviceIntent.putExtra(EXTRA_KEYCODE, event.getKeyCode()); serviceIntent.putExtra(EXTRA_SOURCE, event.getSource()); - context.startService(serviceIntent); + ContextCompat.startForegroundService(context, serviceIntent); } } diff --git a/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java index a90f0f706..edc2ea3e0 100644 --- a/app/src/main/java/de/danoeh/antennapod/receiver/PlayerWidget.java +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java @@ -1,17 +1,15 @@ -package de.danoeh.antennapod.receiver; +package de.danoeh.antennapod.core.receiver; import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.text.TextUtils; import android.util.Log; +import de.danoeh.antennapod.core.service.PlayerWidgetJobService; import java.util.Arrays; -import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.service.PlayerWidgetService; public class PlayerWidget extends AppWidgetProvider { private static final String TAG = "PlayerWidget"; @@ -22,17 +20,7 @@ public class PlayerWidget extends AppWidgetProvider { public void onReceive(Context context, Intent intent) { Log.d(TAG, "onReceive"); super.onReceive(context, intent); - // don't do anything if we're not enabled - if (!isEnabled(context)) { - return; - } - - // these come from the PlaybackService when things should get updated - if (TextUtils.equals(intent.getAction(), PlaybackService.FORCE_WIDGET_UPDATE)) { - startUpdate(context); - } else if (TextUtils.equals(intent.getAction(), PlaybackService.STOP_WIDGET_UPDATE)) { - stopUpdate(context); - } + PlayerWidgetJobService.updateWidget(context); } @Override @@ -40,14 +28,13 @@ public class PlayerWidget extends AppWidgetProvider { super.onEnabled(context); Log.d(TAG, "Widget enabled"); setEnabled(context, true); - startUpdate(context); + PlayerWidgetJobService.updateWidget(context); } @Override - public void onUpdate(Context context, AppWidgetManager appWidgetManager, - int[] appWidgetIds) { + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]"); - startUpdate(context); + PlayerWidgetJobService.updateWidget(context); } @Override @@ -55,20 +42,9 @@ public class PlayerWidget extends AppWidgetProvider { super.onDisabled(context); Log.d(TAG, "Widget disabled"); setEnabled(context, false); - stopUpdate(context); - } - - private void startUpdate(Context context) { - Log.d(TAG, "startUpdate() called with: " + "context = [" + context + "]"); - context.startService(new Intent(context, PlayerWidgetService.class)); - } - - private void stopUpdate(Context context) { - Log.d(TAG, "stopUpdate() called with: " + "context = [" + context + "]"); - context.stopService(new Intent(context, PlayerWidgetService.class)); } - private boolean isEnabled(Context context) { + public static boolean isEnabled(Context context) { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); return prefs.getBoolean(KEY_ENABLED, false); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/FeedUpdateJobService.java b/core/src/main/java/de/danoeh/antennapod/core/service/FeedUpdateJobService.java new file mode 100644 index 000000000..55a8d6b86 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/FeedUpdateJobService.java @@ -0,0 +1,34 @@ +package de.danoeh.antennapod.core.service; + +import android.app.job.JobParameters; +import android.app.job.JobService; +import android.os.Build; +import android.support.annotation.RequiresApi; +import android.util.Log; +import de.danoeh.antennapod.core.ClientConfig; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.FeedUpdateUtils; + +@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) +public class FeedUpdateJobService extends JobService { + private static final String TAG = "FeedUpdateJobService"; + + @Override + public boolean onStartJob(JobParameters params) { + Log.d(TAG, "Job started"); + ClientConfig.initialize(getApplicationContext()); + + FeedUpdateUtils.startAutoUpdate(getApplicationContext(), () -> { + UserPreferences.restartUpdateAlarm(false); + jobFinished(params, false); // needsReschedule = false + }); + + return true; + } + + @Override + public boolean onStopJob(JobParameters params) { + return true; + } + +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java b/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java index a723097a2..de040603d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/GpodnetSyncService.java @@ -3,10 +3,10 @@ package de.danoeh.antennapod.core.service; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; -import android.app.Service; import android.content.Context; import android.content.Intent; -import android.os.IBinder; +import android.support.annotation.NonNull; +import android.support.v4.app.JobIntentService; import android.support.v4.app.NotificationCompat; import android.support.v4.util.ArrayMap; import android.util.Log; @@ -15,6 +15,7 @@ import android.util.Pair; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.R; @@ -37,12 +38,13 @@ 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.util.NetworkUtils; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; /** * Synchronizes local subscriptions with gpodder.net service. The service should be started with ACTION_SYNC as an action argument. * This class also provides static methods for starting the GpodnetSyncService. */ -public class GpodnetSyncService extends Service { +public class GpodnetSyncService extends JobIntentService { private static final String TAG = "GpodnetSyncService"; private static final long WAIT_INTERVAL = 5000L; @@ -55,12 +57,17 @@ public class GpodnetSyncService extends Service { private GpodnetService service; - private boolean syncSubscriptions = false; - private boolean syncActions = false; + private static final AtomicInteger syncActionCount = new AtomicInteger(0); + private static boolean syncSubscriptions = false; + private static boolean syncActions = false; + + private static void enqueueWork(Context context, Intent intent) { + enqueueWork(context, GpodnetSyncService.class, 0, intent); + } @Override - public int onStartCommand(Intent intent, int flags, int startId) { - final String action = (intent != null) ? intent.getStringExtra(ARG_ACTION) : null; + protected void onHandleWork(@NonNull Intent intent) { + final String action = intent.getStringExtra(ARG_ACTION); if (action != null) { switch(action) { case ACTION_SYNC: @@ -78,24 +85,20 @@ public class GpodnetSyncService extends Service { } if(syncSubscriptions || syncActions) { Log.d(TAG, String.format("Waiting %d milliseconds before uploading changes", WAIT_INTERVAL)); - syncWaiterThread.restart(); + int syncActionId = syncActionCount.incrementAndGet(); + try { + Thread.sleep(WAIT_INTERVAL); + } catch (InterruptedException e) { + e.printStackTrace(); + } + if (syncActionId == syncActionCount.get()) { + // onHandleWork was not called again in the meantime + sync(); + } } } else { Log.e(TAG, "Received invalid intent: action argument is null"); } - return START_STICKY; - } - - @Override - public void onDestroy() { - super.onDestroy(); - Log.d(TAG, "onDestroy"); - syncWaiterThread.interrupt(); - } - - @Override - public IBinder onBind(Intent intent) { - return null; } private synchronized GpodnetService tryLogin() throws GpodnetServiceException { @@ -109,6 +112,7 @@ public class GpodnetSyncService extends Service { private synchronized void sync() { if (!GpodnetPreferences.loggedIn() || !NetworkUtils.networkAvailable()) { + stopForeground(true); stopSelf(); return; } @@ -125,7 +129,6 @@ public class GpodnetSyncService extends Service { } syncActions = false; } - stopSelf(); } private synchronized void syncSubscriptionChanges() { @@ -319,7 +322,7 @@ public class GpodnetSyncService extends Service { } PendingIntent activityIntent = ClientConfig.gpodnetCallbacks.getGpodnetSyncServiceErrorNotificationPendingIntent(this); - Notification notification = new NotificationCompat.Builder(this) + Notification notification = new NotificationCompat.Builder(this, NotificationUtils.CHANNEL_ID_ERROR) .setContentTitle(title) .setContentText(description) .setContentIntent(activityIntent) @@ -331,69 +334,11 @@ public class GpodnetSyncService extends Service { nm.notify(id, notification); } - private final WaiterThread syncWaiterThread = new WaiterThread(WAIT_INTERVAL) { - @Override - public void onWaitCompleted() { - sync(); - } - }; - - private abstract class WaiterThread { - private final long waitInterval; - private Thread thread; - - private WaiterThread(long waitInterval) { - this.waitInterval = waitInterval; - reinit(); - } - - public abstract void onWaitCompleted(); - - public void exec() { - if (!thread.isAlive()) { - thread.start(); - } - } - - private void reinit() { - if (thread != null && thread.isAlive()) { - Log.d(TAG, "Interrupting waiter thread"); - thread.interrupt(); - } - thread = new Thread() { - @Override - public void run() { - try { - Thread.sleep(waitInterval); - } catch (InterruptedException e) { - e.printStackTrace(); - } - if (!isInterrupted()) { - synchronized (this) { - onWaitCompleted(); - } - } - } - }; - } - - public void restart() { - reinit(); - exec(); - } - - public void interrupt() { - if (thread != null && thread.isAlive()) { - thread.interrupt(); - } - } - } - public static void sendSyncIntent(Context context) { if (GpodnetPreferences.loggedIn()) { Intent intent = new Intent(context, GpodnetSyncService.class); intent.putExtra(ARG_ACTION, ACTION_SYNC); - context.startService(intent); + enqueueWork(context, intent); } } @@ -401,7 +346,7 @@ public class GpodnetSyncService extends Service { if (GpodnetPreferences.loggedIn()) { Intent intent = new Intent(context, GpodnetSyncService.class); intent.putExtra(ARG_ACTION, ACTION_SYNC_SUBSCRIPTIONS); - context.startService(intent); + enqueueWork(context, intent); } } @@ -409,7 +354,7 @@ public class GpodnetSyncService extends Service { if (GpodnetPreferences.loggedIn()) { Intent intent = new Intent(context, GpodnetSyncService.class); intent.putExtra(ARG_ACTION, ACTION_SYNC_ACTIONS); - context.startService(intent); + enqueueWork(context, intent); } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java b/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java new file mode 100644 index 000000000..01fbcbf56 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java @@ -0,0 +1,197 @@ +package de.danoeh.antennapod.core.service; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.IBinder; +import android.support.annotation.NonNull; +import android.support.v4.app.JobIntentService; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.widget.RemoteViews; + +import com.bumptech.glide.Glide; + +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.glide.ApGlideSettings; +import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.service.playback.PlaybackService; +import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.receiver.PlayerWidget; + +/** + * Updates the state of the player widget + */ +public class PlayerWidgetJobService extends JobIntentService { + private static final String TAG = "PlayerWidgetJobService"; + + private PlaybackService playbackService; + private final Object waitForService = new Object(); + + public static void updateWidget(Context context) { + enqueueWork(context, PlayerWidgetJobService.class, 0, new Intent(context, PlayerWidgetJobService.class)); + } + + @Override + protected void onHandleWork(@NonNull Intent intent) { + if (!PlayerWidget.isEnabled(getApplicationContext())) { + return; + } + + if (PlaybackService.isRunning && playbackService == null) { + synchronized (waitForService) { + bindService(new Intent(this, PlaybackService.class), mConnection, 0); + while (playbackService == null) { + try { + waitForService.wait(); + } catch (InterruptedException e) { + return; + } + } + } + } + + updateViews(); + + if (playbackService != null) { + try { + unbindService(mConnection); + } catch (IllegalArgumentException e) { + Log.w(TAG, "IllegalArgumentException when trying to unbind service"); + } + } + } + + private void updateViews() { + + ComponentName playerWidget = new ComponentName(this, PlayerWidget.class); + AppWidgetManager manager = AppWidgetManager.getInstance(this); + RemoteViews views = new RemoteViews(getPackageName(), R.layout.player_widget); + PendingIntent startMediaplayer = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), 0); + + final PendingIntent startAppPending = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT); + + boolean nothingPlaying = false; + Playable media; + PlayerStatus status; + if (playbackService != null) { + media = playbackService.getPlayable(); + status = playbackService.getStatus(); + } else { + media = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext()); + status = PlayerStatus.STOPPED; + } + + if (media != null) { + views.setOnClickPendingIntent(R.id.layout_left, startMediaplayer); + + try { + Bitmap icon = null; + int iconSize = getResources().getDimensionPixelSize( + android.R.dimen.app_icon_size); + icon = Glide.with(PlayerWidgetJobService.this) + .load(media.getImageLocation()) + .asBitmap() + .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) + .centerCrop() + .into(iconSize, iconSize) + .get(); + views.setImageViewBitmap(R.id.imgvCover, icon); + } catch (Throwable tr) { + Log.e(TAG, "Error loading the media icon for the widget", tr); + } + + views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle()); + + String progressString; + if (playbackService != null) { + progressString = getProgressString(playbackService.getCurrentPosition(), playbackService.getDuration()); + } else { + progressString = getProgressString(media.getPosition(), media.getDuration()); + } + + if (progressString != null) { + views.setViewVisibility(R.id.txtvProgress, View.VISIBLE); + views.setTextViewText(R.id.txtvProgress, progressString); + } + + if (status == PlayerStatus.PLAYING) { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_pause_white_24dp); + if (Build.VERSION.SDK_INT >= 15) { + views.setContentDescription(R.id.butPlay, getString(R.string.pause_label)); + } + } else { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_play_arrow_white_24dp); + if (Build.VERSION.SDK_INT >= 15) { + views.setContentDescription(R.id.butPlay, getString(R.string.play_label)); + } + } + views.setOnClickPendingIntent(R.id.butPlay, createMediaButtonIntent()); + } else { + nothingPlaying = true; + } + + if (nothingPlaying) { + // start the app if they click anything + views.setOnClickPendingIntent(R.id.layout_left, startAppPending); + views.setOnClickPendingIntent(R.id.butPlay, startAppPending); + views.setViewVisibility(R.id.txtvProgress, View.INVISIBLE); + views.setTextViewText(R.id.txtvTitle, + this.getString(R.string.no_media_playing_label)); + views.setImageViewResource(R.id.butPlay, R.drawable.ic_play_arrow_white_24dp); + } + + manager.updateAppWidget(playerWidget, views); + } + + /** + * Creates an intent which fakes a mediabutton press + */ + private PendingIntent createMediaButtonIntent() { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); + Intent startingIntent = new Intent(getBaseContext(), MediaButtonReceiver.class); + startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); + startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); + + return PendingIntent.getBroadcast(this, 0, startingIntent, 0); + } + + private String getProgressString(int position, int duration) { + if (position > 0 && duration > 0) { + return Converter.getDurationStringLong(position) + " / " + + Converter.getDurationStringLong(duration); + } else { + return null; + } + } + + private final ServiceConnection mConnection = new ServiceConnection() { + public void onServiceConnected(ComponentName className, IBinder service) { + Log.d(TAG, "Connection to service established"); + if (service instanceof PlaybackService.LocalBinder) { + synchronized (waitForService) { + playbackService = ((PlaybackService.LocalBinder) service).getService(); + waitForService.notifyAll(); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + playbackService = null; + Log.d(TAG, "Disconnected from service"); + } + + }; +} 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 a62c9d8bf..9c2266622 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 @@ -7,8 +7,6 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; import android.media.MediaMetadataRetriever; import android.os.Binder; import android.os.Build; @@ -22,6 +20,7 @@ import android.util.Log; import android.util.Pair; import android.webkit.URLUtil; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; import org.apache.commons.io.FileUtils; import org.xml.sax.SAXException; @@ -295,6 +294,7 @@ public class DownloadService extends Service { setupNotificationBuilders(); requester = DownloadRequester.getInstance(); + startForeground(NOTIFICATION_ID, updateNotifications()); } @Override @@ -339,7 +339,7 @@ public class DownloadService extends Service { } private void setupNotificationBuilders() { - notificationCompatBuilder = new NotificationCompat.Builder(this) + notificationCompatBuilder = new NotificationCompat.Builder(this, NotificationUtils.CHANNEL_ID_DOWNLOADING) .setOngoing(true) .setContentIntent(ClientConfig.downloadServiceCallbacks.getNotificationContentIntent(this)) .setSmallIcon(R.drawable.stat_notify_sync); @@ -352,7 +352,7 @@ public class DownloadService extends Service { /** * Updates the contents of the service's notifications. Should be called - * before setupNotificationBuilders. + * after setupNotificationBuilders. */ private Notification updateNotifications() { if (notificationCompatBuilder == null) { @@ -499,7 +499,7 @@ public class DownloadService extends Service { if (createReport) { Log.d(TAG, "Creating report"); // create notification object - NotificationCompat.Builder builder = new NotificationCompat.Builder(this) + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, NotificationUtils.CHANNEL_ID_ERROR) .setTicker(getString(R.string.download_report_title)) .setContentTitle(getString(R.string.download_report_content_title)) .setContentText( @@ -551,7 +551,7 @@ public class DownloadService extends Service { final String resourceTitle = (downloadRequest.getTitle() != null) ? downloadRequest.getTitle() : downloadRequest.getSource(); - NotificationCompat.Builder builder = new NotificationCompat.Builder(DownloadService.this); + NotificationCompat.Builder builder = new NotificationCompat.Builder(DownloadService.this, NotificationUtils.CHANNEL_ID_USER_ACTION); builder.setTicker(getText(R.string.authentication_notification_title)) .setContentTitle(getText(R.string.authentication_notification_title)) .setContentText(getText(R.string.authentication_notification_msg)) @@ -1107,7 +1107,7 @@ public class DownloadService extends Service { * that every image reference is unique. */ @VisibleForTesting - public static void removeDuplicateImages(Feed feed) { + static void removeDuplicateImages(Feed feed) { Set<String> known = new HashSet<>(); for (FeedItem item : feed.getItems()) { String url = item.hasItemImage() ? item.getImage().getDownload_url() : null; 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 0bd516f4e..be6cb346d 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 @@ -30,12 +30,10 @@ import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.support.v4.media.session.PlaybackStateCompat; -import android.support.v4.view.InputDeviceCompat; -import android.support.v7.app.NotificationCompat; +import android.support.v4.app.NotificationCompat; import android.text.TextUtils; import android.util.Log; import android.util.Pair; -import android.view.InputDevice; import android.view.KeyEvent; import android.view.SurfaceHolder; import android.widget.Toast; @@ -49,6 +47,7 @@ import java.util.List; import de.danoeh.antennapod.core.ClientConfig; 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.feed.Chapter; import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedItem; @@ -60,11 +59,13 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.service.PlayerWidgetJobService; 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.util.IntList; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; import de.danoeh.antennapod.core.util.QueueAccess; import de.danoeh.antennapod.core.util.playback.ExternalMedia; import de.danoeh.antennapod.core.util.playback.Playable; @@ -74,8 +75,6 @@ import de.greenrobot.event.EventBus; * Controls the MediaPlayer that plays a FeedMedia-file */ public class PlaybackService extends MediaBrowserServiceCompat { - public static final String FORCE_WIDGET_UPDATE = "de.danoeh.antennapod.FORCE_WIDGET_UPDATE"; - public static final String STOP_WIDGET_UPDATE = "de.danoeh.antennapod.STOP_WIDGET_UPDATE"; /** * Logging tag */ @@ -313,6 +312,31 @@ public class PlaybackService extends MediaBrowserServiceCompat { flavorHelper.initializeMediaPlayer(PlaybackService.this); mediaSession.setActive(true); + + NotificationCompat.Builder notificationBuilder = createBasicNotification(); + startForeground(NOTIFICATION_ID, notificationBuilder.build()); + EventBus.getDefault().post(new ServiceEvent(ServiceEvent.Action.SERVICE_STARTED)); + + + setupNotification(Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext())); + } + + private NotificationCompat.Builder createBasicNotification() { + final int smallIcon = ClientConfig.playbackServiceCallbacks.getNotificationIconResource(getApplicationContext()); + + final PendingIntent pIntent = PendingIntent.getActivity(this, 0, + PlaybackService.getPlayerActivityIntent(this), + PendingIntent.FLAG_UPDATE_CURRENT); + + return new NotificationCompat.Builder( + this, NotificationUtils.CHANNEL_ID_PLAYING) + .setContentTitle(getString(R.string.app_name)) + .setContentText("Service is running") // Just in case the notification is not updated (should not occur) + .setOngoing(false) + .setContentIntent(pIntent) + .setWhen(0) // we don't need the time + .setSmallIcon(smallIcon) + .setPriority(NotificationCompat.PRIORITY_MIN); } @Override @@ -487,6 +511,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { } else if (status == PlayerStatus.INITIALIZED) { mediaPlayer.setStartWhenPrepared(true); mediaPlayer.prepare(); + } else if (mediaPlayer.getPlayable() == null) { + startPlayingFromPreferences(); } return true; case KeyEvent.KEYCODE_MEDIA_PLAY: @@ -495,6 +521,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { } else if (status == PlayerStatus.INITIALIZED) { mediaPlayer.setStartWhenPrepared(true); mediaPlayer.prepare(); + } else if (mediaPlayer.getPlayable() == null) { + startPlayingFromPreferences(); } return true; case KeyEvent.KEYCODE_MEDIA_PAUSE: @@ -548,6 +576,15 @@ public class PlaybackService extends MediaBrowserServiceCompat { return false; } + private void startPlayingFromPreferences() { + Playable playable = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext()); + if (playable != null) { + mediaPlayer.playMediaObject(playable, false, true, true); + started = true; + PlaybackService.this.updateMediaSessionMetadata(playable); + } + } + /** * Called by a mediaplayer Activity as soon as it has prepared its * mediaplayer. @@ -566,8 +603,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { } public void notifyVideoSurfaceAbandoned() { - stopForeground(!UserPreferences.isPersistNotify()); + mediaPlayer.pause(true, false); mediaPlayer.resetVideoSurface(); + setupNotification(getPlayable()); + stopForeground(!UserPreferences.isPersistNotify()); } private final PlaybackServiceTaskManager.PSTMCallback taskManagerCallback = new PlaybackServiceTaskManager.PSTMCallback() { @@ -601,7 +640,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { @Override public void onWidgetUpdaterTick() { - updateWidget(); + PlayerWidgetJobService.updateWidget(getBaseContext()); } @Override @@ -663,7 +702,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { Intent statusUpdate = new Intent(ACTION_PLAYER_STATUS_CHANGED); // statusUpdate.putExtra(EXTRA_NEW_PLAYER_STATUS, newInfo.playerStatus.ordinal()); sendBroadcast(statusUpdate); - updateWidget(); + PlayerWidgetJobService.updateWidget(getBaseContext()); bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED); bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED); } @@ -806,7 +845,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { if (!isCasting) { stopForeground(true); } - stopWidgetUpdater(); } if (mediaType == null) { sendNotificationBroadcast(NOTIFICATION_TYPE_PLAYBACK_END, 0); @@ -1170,10 +1208,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { * Prepares notification and starts the service in the foreground. */ private void setupNotification(final PlaybackServiceMediaPlayer.PSMPInfo info) { - final PendingIntent pIntent = PendingIntent.getActivity(this, 0, - PlaybackService.getPlayerActivityIntent(this), - PendingIntent.FLAG_UPDATE_CURRENT); + setupNotification(info.playable); + } + private synchronized void setupNotification(final Playable playable) { if (notificationSetupThread != null) { notificationSetupThread.interrupt(); } @@ -1183,12 +1221,12 @@ public class PlaybackService extends MediaBrowserServiceCompat { @Override public void run() { Log.d(TAG, "Starting background work"); - if (info.playable != null) { + if (playable != null) { int iconSize = getResources().getDimensionPixelSize( android.R.dimen.notification_large_icon_width); try { icon = Glide.with(PlaybackService.this) - .load(info.playable.getImageLocation()) + .load(playable.getImageLocation()) .asBitmap() .diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY) .centerCrop() @@ -1207,24 +1245,18 @@ public class PlaybackService extends MediaBrowserServiceCompat { return; } PlayerStatus playerStatus = mediaPlayer.getPlayerStatus(); - final int smallIcon = ClientConfig.playbackServiceCallbacks.getNotificationIconResource(getApplicationContext()); - if (!Thread.currentThread().isInterrupted() && started && info.playable != null) { - String contentText = info.playable.getEpisodeTitle(); - String contentTitle = info.playable.getFeedTitle(); + if (!Thread.currentThread().isInterrupted() && started && playable != null) { + String contentText = playable.getEpisodeTitle(); + String contentTitle = playable.getFeedTitle(); Notification notification; // Builder is v7, even if some not overwritten methods return its parent's v4 interface - NotificationCompat.Builder notificationBuilder = (NotificationCompat.Builder) new NotificationCompat.Builder( - PlaybackService.this) - .setContentTitle(contentTitle) + NotificationCompat.Builder notificationBuilder = createBasicNotification(); + notificationBuilder.setContentTitle(contentTitle) .setContentText(contentText) - .setOngoing(false) - .setContentIntent(pIntent) - .setLargeIcon(icon) - .setSmallIcon(smallIcon) - .setWhen(0) // we don't need the time - .setPriority(UserPreferences.getNotifyPriority()); // set notification priority + .setPriority(UserPreferences.getNotifyPriority()) + .setLargeIcon(icon); // set notification priority IntList compactActionList = new IntList(); int numActions = 0; // we start and 0 and then increment by 1 for each call to addAction @@ -1292,7 +1324,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { PendingIntent stopButtonPendingIntent = getPendingIntentForMediaAction( KeyEvent.KEYCODE_MEDIA_STOP, numActions); - notificationBuilder.setStyle(new android.support.v7.app.NotificationCompat.MediaStyle() + notificationBuilder.setStyle(new android.support.v4.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.getSessionToken()) .setShowActionsInCompactView(compactActionList.toArray()) .setShowCancelButton(true) @@ -1359,16 +1391,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { } } - private void stopWidgetUpdater() { - taskManager.cancelWidgetUpdater(); - sendBroadcast(new Intent(STOP_WIDGET_UPDATE)); - } - - private void updateWidget() { - PlaybackService.this.sendBroadcast(new Intent( - FORCE_WIDGET_UPDATE)); - } - public boolean sleepTimerActive() { return taskManager.isSleepTimerActive(); } 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 573954412..da500fd3e 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 @@ -4,6 +4,8 @@ import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.database.Cursor; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; import android.util.Log; import java.util.ArrayList; @@ -38,6 +40,7 @@ import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; import de.danoeh.antennapod.core.util.exception.MediaFileNotFoundException; import de.danoeh.antennapod.core.util.flattr.FlattrUtils; +import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import static android.content.Context.MODE_PRIVATE; @@ -123,16 +126,13 @@ public final class DBTasks { media); } } - // Start playback Service - Intent launchIntent = new Intent(context, PlaybackService.class); - launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); - launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, - startWhenPrepared); - launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, - shouldStream); - launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, - true); - context.startService(launchIntent); + + new PlaybackServiceStarter(context, media) + .callEvenIfRunning(true) + .startWhenPrepared(startWhenPrepared) + .shouldStream(shouldStream) + .start(); + if (showPlayer) { // Launch media player context.startActivity(PlaybackService.getPlayerActivityIntent( @@ -155,42 +155,56 @@ public final class DBTasks { * Refreshes a given list of Feeds in a separate Thread. This method might ignore subsequent calls if it is still * enqueuing Feeds for download from a previous call * - * @param context Might be used for accessing the database - * @param feeds List of Feeds that should be refreshed. + * @param context Might be used for accessing the database + * @param feeds List of Feeds that should be refreshed. */ - public static void refreshAllFeeds(final Context context, - final List<Feed> feeds) { - if (isRefreshing.compareAndSet(false, true)) { - new Thread() { - public void run() { - if (feeds != null) { - refreshFeeds(context, feeds); - } else { - refreshFeeds(context, DBReader.getFeedList()); - } - isRefreshing.set(false); + public static void refreshAllFeeds(final Context context, final List<Feed> feeds) { + refreshAllFeeds(context, feeds, null); + } + + /** + * Refreshes a given list of Feeds in a separate Thread. This method might ignore subsequent calls if it is still + * enqueuing Feeds for download from a previous call + * + * @param context Might be used for accessing the database + * @param feeds List of Feeds that should be refreshed. + * @param callback Called after everything was added enqueued for download. Might be null. + */ + public static void refreshAllFeeds(final Context context, final List<Feed> feeds, @Nullable Runnable callback) { + if (!isRefreshing.compareAndSet(false, true)) { + Log.d(TAG, "Ignoring request to refresh all feeds: Refresh lock is locked"); + return; + } - SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, MODE_PRIVATE); - prefs.edit().putLong(PREF_LAST_REFRESH, System.currentTimeMillis()).apply(); + new Thread(() -> { + if (feeds != null) { + refreshFeeds(context, feeds); + } else { + refreshFeeds(context, DBReader.getFeedList()); + } + isRefreshing.set(false); - if (FlattrUtils.hasToken()) { - Log.d(TAG, "Flattring all pending things."); - new FlattrClickWorker(context).executeAsync(); // flattr pending things + SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, MODE_PRIVATE); + prefs.edit().putLong(PREF_LAST_REFRESH, System.currentTimeMillis()).apply(); - Log.d(TAG, "Fetching flattr status."); - new FlattrStatusFetcher(context).start(); + if (FlattrUtils.hasToken()) { + Log.d(TAG, "Flattring all pending things."); + new FlattrClickWorker(context).executeAsync(); // flattr pending things - } - if (ClientConfig.gpodnetCallbacks.gpodnetEnabled()) { - GpodnetSyncService.sendSyncIntent(context); - } - Log.d(TAG, "refreshAllFeeds autodownload"); - autodownloadUndownloadedItems(context); - } - }.start(); - } else { - Log.d(TAG, "Ignoring request to refresh all feeds: Refresh lock is locked"); - } + Log.d(TAG, "Fetching flattr status."); + new FlattrStatusFetcher(context).start(); + + } + if (ClientConfig.gpodnetCallbacks.gpodnetEnabled()) { + GpodnetSyncService.sendSyncIntent(context); + } + Log.d(TAG, "refreshAllFeeds autodownload"); + autodownloadUndownloadedItems(context); + + if (callback != null) { + callback.run(); + } + }).start(); } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java index 1f429d189..7d4b737db 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.v4.content.ContextCompat; import android.text.TextUtils; import android.util.Log; import android.webkit.URLUtil; @@ -81,7 +82,7 @@ public class DownloadRequester { Intent launchIntent = new Intent(context, DownloadService.class); launchIntent.putExtra(DownloadService.EXTRA_REQUEST, request); - context.startService(launchIntent); + ContextCompat.startForegroundService(context, launchIntent); return true; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FeedUpdateUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/FeedUpdateUtils.java new file mode 100644 index 000000000..24e0da9ed --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/FeedUpdateUtils.java @@ -0,0 +1,21 @@ +package de.danoeh.antennapod.core.util; + +import android.content.Context; +import android.util.Log; +import de.danoeh.antennapod.core.storage.DBTasks; + +public class FeedUpdateUtils { + private static final String TAG = "FeedUpdateUtils"; + + private FeedUpdateUtils() { + + } + + public static void startAutoUpdate(Context context, Runnable callback) { + if (NetworkUtils.networkAvailable() && NetworkUtils.isDownloadAllowed()) { + DBTasks.refreshAllFeeds(context, null, callback); + } else { + Log.d(TAG, "Blocking automatic update: no wifi available / no mobile updates allowed"); + } + } +} 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 new file mode 100644 index 000000000..ad723c685 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/download/AutoUpdateManager.java @@ -0,0 +1,155 @@ +package de.danoeh.antennapod.core.util.download; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.app.job.JobInfo; +import android.app.job.JobScheduler; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.SystemClock; +import android.support.annotation.RequiresApi; +import android.util.Log; +import de.danoeh.antennapod.core.receiver.FeedUpdateReceiver; +import de.danoeh.antennapod.core.service.FeedUpdateJobService; + +import java.util.Calendar; +import java.util.concurrent.TimeUnit; + +public class AutoUpdateManager { + private static final int JOB_ID_FEED_UPDATE = 42; + private static final String TAG = "AutoUpdateManager"; + + private AutoUpdateManager() { + + } + + /** + * Sets the interval in which the feeds are refreshed automatically + */ + public static void restartUpdateIntervalAlarm(Context context, long triggerAtMillis, long intervalMillis) { + Log.d(TAG, "Restarting update alarm."); + + if (Build.VERSION.SDK_INT >= 24) { + restartJobServiceInterval(context, intervalMillis); + } else { + restartAlarmManagerInterval(context, triggerAtMillis, intervalMillis); + } + } + + /** + * Sets time of day the feeds are refreshed automatically + */ + public static void restartUpdateTimeOfDayAlarm(Context context, int hoursOfDay, int minute) { + Log.d(TAG, "Restarting update alarm."); + + Calendar now = Calendar.getInstance(); + Calendar alarm = (Calendar)now.clone(); + alarm.set(Calendar.HOUR_OF_DAY, hoursOfDay); + alarm.set(Calendar.MINUTE, minute); + if (alarm.before(now) || alarm.equals(now)) { + alarm.add(Calendar.DATE, 1); + } + + if (Build.VERSION.SDK_INT >= 24) { + long triggerAtMillis = alarm.getTimeInMillis() - now.getTimeInMillis(); + restartJobServiceTriggerAt(context, triggerAtMillis); + } else { + restartAlarmManagerTimeOfDay(context, alarm); + } + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private static JobInfo.Builder getFeedUpdateJobBuilder(Context context) { + ComponentName serviceComponent = new ComponentName(context, FeedUpdateJobService.class); + JobInfo.Builder builder = new JobInfo.Builder(JOB_ID_FEED_UPDATE, serviceComponent); + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + builder.setPersisted(true); + return builder; + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private static void restartJobServiceInterval(Context context, long intervalMillis) { + JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); + if (jobScheduler == null) { + Log.d(TAG, "JobScheduler was null."); + return; + } + + JobInfo oldJob = jobScheduler.getPendingJob(JOB_ID_FEED_UPDATE); + if (oldJob != null && oldJob.getIntervalMillis() == intervalMillis) { + Log.d(TAG, "JobScheduler was already set at interval " + intervalMillis + ", ignoring."); + return; + } + + JobInfo.Builder builder = getFeedUpdateJobBuilder(context); + builder.setPeriodic(intervalMillis); + jobScheduler.cancel(JOB_ID_FEED_UPDATE); + + if (intervalMillis <= 0) { + Log.d(TAG, "Automatic update was deactivated"); + return; + } + + jobScheduler.schedule(builder.build()); + Log.d(TAG, "JobScheduler was set at interval " + intervalMillis); + } + + private static void restartAlarmManagerInterval(Context context, long triggerAtMillis, long intervalMillis) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (alarmManager == null) { + Log.d(TAG, "AlarmManager was null"); + return; + } + + PendingIntent updateIntent = PendingIntent.getBroadcast(context, 0, + new Intent(context, FeedUpdateReceiver.class), 0); + alarmManager.cancel(updateIntent); + + if (intervalMillis <= 0) { + Log.d(TAG, "Automatic update was deactivated"); + return; + } + + alarmManager.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + triggerAtMillis, + updateIntent); + Log.d(TAG, "Changed alarm to new interval " + TimeUnit.MILLISECONDS.toHours(intervalMillis) + " h"); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private static void restartJobServiceTriggerAt(Context context, long triggerAtMillis) { + JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); + if (jobScheduler == null) { + Log.d(TAG, "JobScheduler was null."); + return; + } + + JobInfo.Builder builder = getFeedUpdateJobBuilder(context); + builder.setMinimumLatency(triggerAtMillis); + jobScheduler.cancel(JOB_ID_FEED_UPDATE); + jobScheduler.schedule(builder.build()); + Log.d(TAG, "JobScheduler was set for " + triggerAtMillis); + } + + private static void restartAlarmManagerTimeOfDay(Context context, Calendar alarm) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + + if (alarmManager == null) { + Log.d(TAG, "AlarmManager was null"); + return; + } + + PendingIntent updateIntent = PendingIntent.getBroadcast(context, 0, + new Intent(context, FeedUpdateReceiver.class), 0); + alarmManager.cancel(updateIntent); + + Log.d(TAG, "Alarm set for: " + alarm.toString() + " : " + alarm.getTimeInMillis()); + alarmManager.set(AlarmManager.RTC_WAKEUP, + alarm.getTimeInMillis(), + updateIntent); + Log.d(TAG, "Changed alarm to new time of day " + alarm.get(Calendar.HOUR_OF_DAY) + ":" + alarm.get(Calendar.MINUTE)); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java new file mode 100644 index 000000000..1c42364ea --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java @@ -0,0 +1,62 @@ +package de.danoeh.antennapod.core.util.gui; + + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; +import android.support.annotation.RequiresApi; +import de.danoeh.antennapod.core.R; + +public class NotificationUtils { + public static final String CHANNEL_ID_USER_ACTION = "user_action"; + public static final String CHANNEL_ID_DOWNLOADING = "downloading"; + public static final String CHANNEL_ID_PLAYING = "playing"; + public static final String CHANNEL_ID_ERROR = "error"; + + public static void createChannels(Context context) { + if (android.os.Build.VERSION.SDK_INT < 26) { + return; + } + NotificationManager mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + if (mNotificationManager != null) { + mNotificationManager.createNotificationChannel(createChannelUserAction(context)); + mNotificationManager.createNotificationChannel(createChannelDownloading(context)); + mNotificationManager.createNotificationChannel(createChannelPlaying(context)); + mNotificationManager.createNotificationChannel(createChannelError(context)); + } + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static NotificationChannel createChannelUserAction(Context c) { + NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_USER_ACTION, + c.getString(R.string.notification_channel_user_action), NotificationManager.IMPORTANCE_HIGH); + mChannel.setDescription(c.getString(R.string.notification_channel_user_action_description)); + return mChannel; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static NotificationChannel createChannelDownloading(Context c) { + NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_DOWNLOADING, + c.getString(R.string.notification_channel_downloading), NotificationManager.IMPORTANCE_LOW); + mChannel.setDescription(c.getString(R.string.notification_channel_downloading_description)); + return mChannel; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static NotificationChannel createChannelPlaying(Context c) { + NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_PLAYING, + c.getString(R.string.notification_channel_playing), NotificationManager.IMPORTANCE_LOW); + mChannel.setDescription(c.getString(R.string.notification_channel_playing_description)); + return mChannel; + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private static NotificationChannel createChannelError(Context c) { + NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_ERROR, + c.getString(R.string.notification_channel_error), NotificationManager.IMPORTANCE_HIGH); + mChannel.setDescription(c.getString(R.string.notification_channel_error_description)); + return mChannel; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java index 279c56338..ff7f5b79d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -3,6 +3,8 @@ package de.danoeh.antennapod.core.util.playback; import android.content.Context; import android.content.SharedPreferences; import android.os.Parcelable; +import android.preference.PreferenceManager; +import android.support.annotation.Nullable; import android.util.Log; import java.util.List; @@ -11,6 +13,7 @@ import de.danoeh.antennapod.core.asynctask.ImageResource; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.util.ShownotesProvider; @@ -179,6 +182,23 @@ public interface Playable extends Parcelable, * Restores a playable object from a sharedPreferences file. This method might load data from the database, * depending on the type of playable that was restored. * + * @return The restored Playable object + */ + @Nullable + public static Playable createInstanceFromPreferences(Context context) { + long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMedia(); + if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + return PlayableUtils.createInstanceFromPreferences(context, + (int) currentlyPlayingMedia, prefs); + } + return null; + } + + /** + * Restores a playable object from a sharedPreferences file. This method might load data from the database, + * depending on the type of playable that was restored. + * * @param type An integer that represents the type of the Playable object * that is restored. * @param pref The SharedPreferences file from which the Playable object 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 a160b4f0a..a3f02d5cc 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 @@ -14,6 +14,8 @@ import android.os.Build; import android.os.IBinder; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; import android.text.TextUtils; import android.util.Log; import android.util.Pair; @@ -59,7 +61,7 @@ public abstract class PlaybackController { private PlaybackService playbackService; private Playable media; - private PlayerStatus status; + private PlayerStatus status = PlayerStatus.STOPPED; private final ScheduledThreadPoolExecutor schedExecutor; private static final int SCHED_EX_POOLSIZE = 1; @@ -69,6 +71,7 @@ public abstract class PlaybackController { private boolean mediaInfoLoaded = false; private boolean released = false; + private boolean initialized = false; private Subscription serviceBinder; @@ -92,10 +95,22 @@ public abstract class PlaybackController { } /** - * Creates a new connection to the playbackService. Should be called in the - * activity's onResume() method. + * Creates a new connection to the playbackService. */ public void init() { + if (PlaybackService.isRunning) { + initServiceRunning(); + } else { + initServiceNotRunning(); + } + } + + private synchronized void initServiceRunning() { + if (initialized) { + return; + } + initialized = true; + activity.registerReceiver(statusUpdate, new IntentFilter( PlaybackService.ACTION_PLAYER_STATUS_CHANGED)); @@ -167,7 +182,7 @@ public abstract class PlaybackController { */ private void bindToService() { Log.d(TAG, "Trying to connect to service"); - if(serviceBinder != null) { + if (serviceBinder != null) { serviceBinder.unsubscribe(); } serviceBinder = Observable.fromCallable(this::getPlayLastPlayedMediaIntent) @@ -178,7 +193,7 @@ public abstract class PlaybackController { if (!PlaybackService.started) { if (intent != null) { Log.d(TAG, "Calling start service"); - activity.startService(intent); + ContextCompat.startForegroundService(activity, intent); bound = activity.bindService(intent, mConnection, 0); } else { status = PlayerStatus.STOPPED; @@ -198,31 +213,24 @@ public abstract class PlaybackController { * Returns an intent that starts the PlaybackService and plays the last * played media or null if no last played media could be found. */ - private Intent getPlayLastPlayedMediaIntent() { + @Nullable private Intent getPlayLastPlayedMediaIntent() { Log.d(TAG, "Trying to restore last played media"); - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( - activity.getApplicationContext()); - long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMedia(); - if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) { - Playable media = PlayableUtils.createInstanceFromPreferences(activity, - (int) currentlyPlayingMedia, prefs); - if (media != null) { - Intent serviceIntent = new Intent(activity, PlaybackService.class); - serviceIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); - serviceIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, false); - serviceIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, true); - boolean fileExists = media.localFileAvailable(); - boolean lastIsStream = PlaybackPreferences.getCurrentEpisodeIsStream(); - if (!fileExists && !lastIsStream && media instanceof FeedMedia) { - DBTasks.notifyMissingFeedMediaFile(activity, (FeedMedia) media); - } - serviceIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, - lastIsStream || !fileExists); - return serviceIntent; - } + Playable media = PlayableUtils.createInstanceFromPreferences(activity); + if (media == null) { + Log.d(TAG, "No last played media found"); + return null; } - Log.d(TAG, "No last played media found"); - return null; + + boolean fileExists = media.localFileAvailable(); + boolean lastIsStream = PlaybackPreferences.getCurrentEpisodeIsStream(); + if (!fileExists && !lastIsStream && media instanceof FeedMedia) { + DBTasks.notifyMissingFeedMediaFile(activity, (FeedMedia) media); + } + + return new PlaybackServiceStarter(activity, media) + .startWhenPrepared(false) + .shouldStream(lastIsStream || !fileExists) + .getIntent(); } @@ -511,7 +519,7 @@ public abstract class PlaybackController { "PlaybackService has no media object. Trying to restore last played media."); Intent serviceIntent = getPlayLastPlayedMediaIntent(); if (serviceIntent != null) { - activity.startService(serviceIntent); + ContextCompat.startForegroundService(activity, serviceIntent); } } */ @@ -576,6 +584,10 @@ public abstract class PlaybackController { public void playPause() { if (playbackService == null) { + new PlaybackServiceStarter(activity, media) + .startWhenPrepared(true) + .streamIfLastWasStream() + .start(); Log.w(TAG, "Play/Pause button was pressed, but playbackservice was null!"); return; } @@ -609,6 +621,8 @@ public abstract class PlaybackController { public int getPosition() { if (playbackService != null) { return playbackService.getCurrentPosition(); + } else if (media != null) { + return media.getPosition(); } else { return PlaybackService.INVALID_TIME; } @@ -617,12 +631,17 @@ public abstract class PlaybackController { public int getDuration() { if (playbackService != null) { return playbackService.getDuration(); + } else if (media != null) { + return media.getDuration(); } else { return PlaybackService.INVALID_TIME; } } public Playable getMedia() { + if (media == null) { + media = PlayableUtils.createInstanceFromPreferences(activity); + } return media; } @@ -714,8 +733,13 @@ public abstract class PlaybackController { } public boolean isPlayingVideoLocally() { - return playbackService != null && PlaybackService.getCurrentMediaType() == MediaType.VIDEO - && !PlaybackService.isCasting(); + if (PlaybackService.isCasting()) { + return false; + } else if (playbackService != null) { + return PlaybackService.getCurrentMediaType() == MediaType.VIDEO; + } else { + return getMedia() != null && getMedia().getMediaType() == MediaType.VIDEO; + } } public Pair<Integer, Integer> getVideoSize() { @@ -755,6 +779,21 @@ public abstract class PlaybackController { } } + private void initServiceNotRunning() { + if (getMedia() == null) { + return; + } + if (getMedia().getMediaType() == MediaType.AUDIO) { + TypedArray res = activity.obtainStyledAttributes(new int[]{ + de.danoeh.antennapod.core.R.attr.av_play_big}); + getPlayButton().setImageResource( + res.getResourceId(0, de.danoeh.antennapod.core.R.drawable.ic_play_arrow_grey600_36dp)); + res.recycle(); + } else { + getPlayButton().setImageResource(R.drawable.ic_av_play_circle_outline_80dp); + } + } + /** * Refreshes the current position of the media file that is playing. */ diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java new file mode 100644 index 000000000..3ba553d12 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java @@ -0,0 +1,76 @@ +package de.danoeh.antennapod.core.util.playback; + +import android.content.Context; +import android.content.Intent; +import android.media.MediaPlayer; +import android.support.v4.content.ContextCompat; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.service.playback.PlaybackService; + +public class PlaybackServiceStarter { + private final Context context; + private final Playable media; + private boolean startWhenPrepared = false; + private boolean shouldStream = false; + private boolean callEvenIfRunning = false; + private boolean prepareImmediately = true; + + public PlaybackServiceStarter(Context context, Playable media) { + this.context = context; + this.media = media; + } + + /** + * Default value: false + */ + public PlaybackServiceStarter shouldStream(boolean shouldStream) { + this.shouldStream = shouldStream; + return this; + } + + public PlaybackServiceStarter streamIfLastWasStream() { + boolean lastIsStream = PlaybackPreferences.getCurrentEpisodeIsStream(); + return shouldStream(lastIsStream); + } + + /** + * Default value: false + */ + public PlaybackServiceStarter startWhenPrepared(boolean startWhenPrepared) { + this.startWhenPrepared = startWhenPrepared; + return this; + } + + /** + * Default value: false + */ + public PlaybackServiceStarter callEvenIfRunning(boolean callEvenIfRunning) { + this.callEvenIfRunning = callEvenIfRunning; + return this; + } + + /** + * Default value: true + */ + public PlaybackServiceStarter prepareImmediately(boolean prepareImmediately) { + this.prepareImmediately = prepareImmediately; + return this; + } + + public Intent getIntent() { + Intent launchIntent = new Intent(context, PlaybackService.class); + launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); + launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, startWhenPrepared); + launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, shouldStream); + launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, prepareImmediately); + + return launchIntent; + } + + public void start() { + if (PlaybackService.isRunning && !callEvenIfRunning) { + return; + } + ContextCompat.startForegroundService(context, getIntent()); + } +} diff --git a/app/src/main/res/layout/player_widget.xml b/core/src/main/res/layout/player_widget.xml index cabebc83f..cabebc83f 100644 --- a/app/src/main/res/layout/player_widget.xml +++ b/core/src/main/res/layout/player_widget.xml diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 5c06d34bd..44288b24a 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -30,6 +30,7 @@ <string name="free_space_label">%1$s free</string> <string name="episode_cache_full_title">Episode cache full</string> <string name="episode_cache_full_message">The episode cache limit has been reached. You can increase the cache size in the Settings.</string> + <string name="synchronizing">Synchronizing…</string> <!-- Statistics fragment --> <string name="total_time_listened_to_podcasts">Total time of podcasts played:</string> @@ -710,4 +711,14 @@ <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_channel_user_action">Action required</string> + <string name="notification_channel_user_action_description">Shown if your action is required, for example if you need to enter a password.</string> + <string name="notification_channel_downloading">Downloading</string> + <string name="notification_channel_downloading_description">Shown while currently downloading.</string> + <string name="notification_channel_playing">Currently playing</string> + <string name="notification_channel_playing_description">Allows to control playback. This is the main notification you see while playing a podcast.</string> + <string name="notification_channel_error">Errors</string> + <string name="notification_channel_error_description">Shown if something went wrong, for example if download or gpodder sync fails.</string> </resources> diff --git a/core/src/test/java/android/text/TextUtils.java b/core/src/test/java/android/text/TextUtils.java new file mode 100644 index 000000000..c31234171 --- /dev/null +++ b/core/src/test/java/android/text/TextUtils.java @@ -0,0 +1,32 @@ +package android.text; + +/** + * A slim-down version of standard {@link android.text.TextUtils} to be used in unit tests. + */ +public class TextUtils { + + /** + * Returns true if a and b are equal, including if they are both null. + * <p><i>Note: In platform versions 1.1 and earlier, this method only worked well if + * both the arguments were instances of String.</i></p> + * @param a first CharSequence to check + * @param b second CharSequence to check + * @return true if a and b are equal + */ + public static boolean equals(CharSequence a, CharSequence b) { + if (a == b) return true; + int length; + if (a != null && b != null && (length = a.length()) == b.length()) { + if (a instanceof String && b instanceof String) { + return a.equals(b); + } else { + for (int i = 0; i < length; i++) { + if (a.charAt(i) != b.charAt(i)) return false; + } + return true; + } + } + return false; + } + +} diff --git a/core/src/test/java/android/util/Log.java b/core/src/test/java/android/util/Log.java new file mode 100644 index 000000000..881d10209 --- /dev/null +++ b/core/src/test/java/android/util/Log.java @@ -0,0 +1,245 @@ +package android.util; + +import java.io.PrintWriter; +import java.io.StringWriter; + +/** + * A stub for {@link android.util.Log} to be used in unit tests. + * + * It outputs the log statements to standard error. + */ +public final class Log { + + /** + * Priority constant for the println method; use Log.v. + */ + public static final int VERBOSE = 2; + + /** + * Priority constant for the println method; use Log.d. + */ + public static final int DEBUG = 3; + + /** + * Priority constant for the println method; use Log.i. + */ + public static final int INFO = 4; + + /** + * Priority constant for the println method; use Log.w. + */ + public static final int WARN = 5; + + /** + * Priority constant for the println method; use Log.e. + */ + public static final int ERROR = 6; + + /** + * Priority constant for the println method. + */ + public static final int ASSERT = 7; + + private Log() { + } + + /** + * Send a {@link #VERBOSE} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static int v(String tag, String msg) { + return println_native(LOG_ID_MAIN, VERBOSE, tag, msg); + } + + /** + * Send a {@link #VERBOSE} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static int v(String tag, String msg, Throwable tr) { + return printlns(LOG_ID_MAIN, VERBOSE, tag, msg, tr); + } + + /** + * Send a {@link #DEBUG} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static int d(String tag, String msg) { + return println_native(LOG_ID_MAIN, DEBUG, tag, msg); + } + + /** + * Send a {@link #DEBUG} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static int d(String tag, String msg, Throwable tr) { + return printlns(LOG_ID_MAIN, DEBUG, tag, msg, tr); + } + + /** + * Send an {@link #INFO} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static int i(String tag, String msg) { + return println_native(LOG_ID_MAIN, INFO, tag, msg); + } + + /** + * Send a {@link #INFO} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static int i(String tag, String msg, Throwable tr) { + return printlns(LOG_ID_MAIN, INFO, tag, msg, tr); + } + + /** + * Send a {@link #WARN} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static int w(String tag, String msg) { + return println_native(LOG_ID_MAIN, WARN, tag, msg); + } + + /** + * Send a {@link #WARN} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static int w(String tag, String msg, Throwable tr) { + return printlns(LOG_ID_MAIN, WARN, tag, msg, tr); + } + + /** + * Checks to see whether or not a log for the specified tag is loggable at the specified level. + * + * @return true in all cases (for unit test environment) + */ + public static boolean isLoggable(String tag, int level) { + return true; + } + + /* + * Send a {@link #WARN} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param tr An exception to log + */ + public static int w(String tag, Throwable tr) { + return printlns(LOG_ID_MAIN, WARN, tag, "", tr); + } + + /** + * Send an {@link #ERROR} log message. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + */ + public static int e(String tag, String msg) { + return println_native(LOG_ID_MAIN, ERROR, tag, msg); + } + + /** + * Send a {@link #ERROR} log message and log the exception. + * @param tag Used to identify the source of a log message. It usually identifies + * the class or activity where the log call occurs. + * @param msg The message you would like logged. + * @param tr An exception to log + */ + public static int e(String tag, String msg, Throwable tr) { + return printlns(LOG_ID_MAIN, ERROR, tag, msg, tr); + } + + /** + * What a Terrible Failure: Report a condition that should never happen. + * The error will always be logged at level ASSERT with the call stack. + * Depending on system configuration, a report may be added to the + * {@link android.os.DropBoxManager} and/or the process may be terminated + * immediately with an error dialog. + * @param tag Used to identify the source of a log message. + * @param msg The message you would like logged. + */ + public static int wtf(String tag, String msg) { + return wtf(LOG_ID_MAIN, tag, msg, null, false, false); + } + + /** + * Like {@link #wtf(String, String)}, but also writes to the log the full + * call stack. + * @hide + */ + public static int wtfStack(String tag, String msg) { + return wtf(LOG_ID_MAIN, tag, msg, null, true, false); + } + + /** + * What a Terrible Failure: Report an exception that should never happen. + * Similar to {@link #wtf(String, String)}, with an exception to log. + * @param tag Used to identify the source of a log message. + * @param tr An exception to log. + */ + public static int wtf(String tag, Throwable tr) { + return wtf(LOG_ID_MAIN, tag, tr.getMessage(), tr, false, false); + } + + /** + * What a Terrible Failure: Report an exception that should never happen. + * Similar to {@link #wtf(String, Throwable)}, with a message as well. + * @param tag Used to identify the source of a log message. + * @param msg The message you would like logged. + * @param tr An exception to log. May be null. + */ + public static int wtf(String tag, String msg, Throwable tr) { + return wtf(LOG_ID_MAIN, tag, msg, tr, false, false); + } + + /** + * Priority Constant for wtf. + * Added for this custom Log implementation, not in android sources. + */ + private static final int WTF = 8; + static int wtf(int logId, String tag, String msg, Throwable tr, boolean localStack, + boolean system) { + return printlns(LOG_ID_MAIN, WTF, tag, msg, tr); + } + + private static final int LOG_ID_MAIN = 0; + + private static final String[] PRIORITY_ABBREV = { "0", "1", "V", "D", "I", "W", "E", "A", "WTF" }; + + private static int println_native(int bufID, int priority, String tag, String msg) { + String res = PRIORITY_ABBREV[priority] + "/" + tag + " " + msg + System.lineSeparator(); + System.err.print(res); + return res.length(); + } + + private static int printlns(int bufID, int priority, String tag, String msg, + Throwable tr) { + StringWriter trSW = new StringWriter(); + if (tr != null) { + trSW.append(" , Exception: "); + PrintWriter trPW = new PrintWriter(trSW); + tr.printStackTrace(trPW); + trPW.flush(); + } + return println_native(bufID, priority, tag, msg + trSW.toString()); + } + +} diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedImageMother.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedImageMother.java index 0fb4992ba..0fb4992ba 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedImageMother.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedImageMother.java diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedItemMother.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemMother.java index 3d7c4fe5f..3d7c4fe5f 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedItemMother.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemMother.java diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedItemTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java index 9e12e8ae0..92aacd9d7 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedItemTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java @@ -1,22 +1,26 @@ package de.danoeh.antennapod.core.feed; -import android.test.AndroidTestCase; +import org.junit.Before; +import org.junit.Test; import static de.danoeh.antennapod.core.feed.FeedItemMother.anyFeedItemWithImage; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; -public class FeedItemTest extends AndroidTestCase { +public class FeedItemTest { private FeedItem original; private FeedImage originalImage; private FeedItem changedFeedItem; - @Override - protected void setUp() { + @Before + public void setUp() { original = anyFeedItemWithImage(); originalImage = original.getImage(); changedFeedItem = anyFeedItemWithImage(); } + @Test public void testUpdateFromOther_feedItemImageDownloadUrlChanged() throws Exception { setNewFeedItemImageDownloadUrl(); @@ -25,6 +29,7 @@ public class FeedItemTest extends AndroidTestCase { feedItemImageWasUpdated(); } + @Test public void testUpdateFromOther_feedItemImageRemoved() throws Exception { feedItemImageRemoved(); @@ -33,6 +38,7 @@ public class FeedItemTest extends AndroidTestCase { feedItemImageWasNotUpdated(); } + @Test public void testUpdateFromOther_feedItemImageAdded() throws Exception { feedItemHadNoImage(); setNewFeedItemImageDownloadUrl(); diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedMother.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java index fecc8e377..fecc8e377 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedMother.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedTest.java index 8067ec93f..55f3bdafe 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/feed/FeedTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedTest.java @@ -1,41 +1,49 @@ package de.danoeh.antennapod.core.feed; -import android.test.AndroidTestCase; +import org.junit.Before; +import org.junit.Test; import static de.danoeh.antennapod.core.feed.FeedImageMother.anyFeedImage; import static de.danoeh.antennapod.core.feed.FeedMother.anyFeed; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; -public class FeedTest extends AndroidTestCase { +public class FeedTest { private Feed original; private FeedImage originalImage; private Feed changedFeed; - @Override - protected void setUp() { + @Before + public void setUp() { original = anyFeed(); originalImage = original.getImage(); changedFeed = anyFeed(); } + @Test public void testCompareWithOther_feedImageDownloadUrlChanged() throws Exception { setNewFeedImageDownloadUrl(); feedHasChanged(); } + @Test public void testCompareWithOther_sameFeedImage() throws Exception { changedFeed.setImage(anyFeedImage()); feedHasNotChanged(); } + @Test public void testCompareWithOther_feedImageRemoved() throws Exception { feedImageRemoved(); feedHasNotChanged(); } + @Test public void testUpdateFromOther_feedImageDownloadUrlChanged() throws Exception { setNewFeedImageDownloadUrl(); @@ -44,6 +52,7 @@ public class FeedTest extends AndroidTestCase { feedImageWasUpdated(); } + @Test public void testUpdateFromOther_feedImageRemoved() throws Exception { feedImageRemoved(); @@ -52,6 +61,7 @@ public class FeedTest extends AndroidTestCase { feedImageWasNotUpdated(); } + @Test public void testUpdateFromOther_feedImageAdded() throws Exception { feedHadNoImage(); setNewFeedImageDownloadUrl(); diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/service/download/DownloadServiceTest.java b/core/src/test/java/de/danoeh/antennapod/core/service/download/DownloadServiceTest.java index 94cfb3278..e40de2064 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/service/download/DownloadServiceTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/service/download/DownloadServiceTest.java @@ -1,6 +1,7 @@ -package de.danoeh.antennapod.core.tests.util.service.download; +package de.danoeh.antennapod.core.service.download; -import android.test.AndroidTestCase; + +import org.junit.Test; import java.util.ArrayList; import java.util.List; @@ -8,10 +9,12 @@ import java.util.List; import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedImage; import de.danoeh.antennapod.core.feed.FeedItem; -import de.danoeh.antennapod.core.service.download.DownloadService; -public class DownloadServiceTest extends AndroidTestCase { +import static org.junit.Assert.assertEquals; + +public class DownloadServiceTest { + @Test public void testRemoveDuplicateImages() { List<FeedItem> items = new ArrayList<>(); for (int i = 0; i < 50; i++) { diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/LongLongMapTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/LongLongMapTest.java index 50c2a9c3c..0ed77eb9f 100644 --- a/core/src/androidTest/java/de/danoeh/antennapod/core/tests/util/LongLongMapTest.java +++ b/core/src/test/java/de/danoeh/antennapod/core/util/LongLongMapTest.java @@ -1,11 +1,12 @@ -package de.danoeh.antennapod.core.tests.util; +package de.danoeh.antennapod.core.util; -import android.test.AndroidTestCase; +import org.junit.Test; -import de.danoeh.antennapod.core.util.LongIntMap; +import static org.junit.Assert.assertEquals; -public class LongLongMapTest extends AndroidTestCase { +public class LongLongMapTest { + @Test public void testEmptyMap() { LongIntMap map = new LongIntMap(); assertEquals(0, map.size()); @@ -18,6 +19,7 @@ public class LongLongMapTest extends AndroidTestCase { assertEquals(1, map.hashCode()); } + @Test public void testSingleElement() { LongIntMap map = new LongIntMap(); map.put(17, 42); @@ -30,6 +32,7 @@ public class LongLongMapTest extends AndroidTestCase { assertEquals(true, map.delete(17)); } + @Test public void testAddAndDelete() { LongIntMap map = new LongIntMap(); for(int i=0; i < 100; i++) { @@ -46,6 +49,7 @@ public class LongLongMapTest extends AndroidTestCase { } } + @Test public void testOverwrite() { LongIntMap map = new LongIntMap(); map.put(17, 42); |